feat: 增加对 Windows CLI 工具的支持,允许使用 cmd 作为首选 shell,并改进错误处理

This commit is contained in:
catlog22
2026-02-20 11:14:22 +08:00
parent 113d0bd234
commit d6bf941113
10 changed files with 174 additions and 28 deletions

View File

@@ -4,4 +4,19 @@
* Entry point for running CCW tools as an MCP server
*/
import '../dist/mcp-server/index.js';
// IMPORTANT:
// MCP stdio servers must not write arbitrary text to stdout.
// stdout is reserved for JSON-RPC protocol messages.
// Redirect common console output to stderr to avoid breaking handshake.
const toStderr = (...args) => console.error(...args);
console.log = toStderr;
console.info = toStderr;
console.debug = toStderr;
console.dir = toStderr;
try {
await import('../dist/mcp-server/index.js');
} catch (err) {
console.error('[ccw-mcp] Failed to start MCP server:', err);
process.exit(1);
}

View File

@@ -29,7 +29,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
export type CliTool = 'claude' | 'gemini' | 'qwen' | 'codex' | 'opencode';
export type LaunchMode = 'default' | 'yolo';
export type ShellKind = 'bash' | 'pwsh';
export type ShellKind = 'bash' | 'pwsh' | 'cmd';
export interface CliSessionConfig {
tool: CliTool;
@@ -69,7 +69,10 @@ export function CliConfigModal({
const [tool, setTool] = React.useState<CliTool>('gemini');
const [model, setModel] = React.useState<string | undefined>(MODEL_OPTIONS.gemini[0]);
const [launchMode, setLaunchMode] = React.useState<LaunchMode>('yolo');
const [preferredShell, setPreferredShell] = React.useState<ShellKind>('bash');
// Default to 'cmd' on Windows for better compatibility with npm CLI tools (.cmd files)
const [preferredShell, setPreferredShell] = React.useState<ShellKind>(
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win') ? 'cmd' : 'bash'
);
const [workingDir, setWorkingDir] = React.useState<string>(defaultWorkingDir ?? '');
const [isSubmitting, setIsSubmitting] = React.useState(false);
@@ -216,8 +219,9 @@ export function CliConfigModal({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">bash</SelectItem>
<SelectItem value="pwsh">pwsh</SelectItem>
<SelectItem value="cmd">cmd ( Windows)</SelectItem>
<SelectItem value="bash">bash (Git Bash/WSL)</SelectItem>
<SelectItem value="pwsh">pwsh (PowerShell)</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -46,6 +46,7 @@ import {
import { useIssues, useIssueQueue } from '@/hooks/useIssues';
import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/terminalGridStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { toast } from '@/stores/notificationStore';
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
// ========== Types ==========
@@ -79,6 +80,7 @@ const LAYOUT_PRESETS = [
];
type LaunchMode = 'default' | 'yolo';
type ShellKind = 'bash' | 'pwsh' | 'cmd';
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
type CliTool = (typeof CLI_TOOLS)[number];
@@ -124,6 +126,9 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
const [isCreating, setIsCreating] = useState(false);
const [selectedTool, setSelectedTool] = useState<CliTool>('gemini');
const [launchMode, setLaunchMode] = useState<LaunchMode>('yolo');
const [selectedShell, setSelectedShell] = useState<ShellKind>(
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win') ? 'cmd' : 'bash'
);
const [isConfigOpen, setIsConfigOpen] = useState(false);
// Helper to get or create a focused pane
@@ -140,18 +145,29 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
setIsCreating(true);
try {
const targetPaneId = getOrCreateFocusedPane();
if (!targetPaneId) return;
if (!targetPaneId) {
toast.error('无法创建会话', '未能获取或创建窗格');
return;
}
await createSessionAndAssign(targetPaneId, {
workingDir: projectPath,
preferredShell: 'bash',
preferredShell: selectedShell,
tool: selectedTool,
launchMode,
}, projectPath);
} catch (error: unknown) {
// Handle both Error instances and ApiError-like objects
const message = error instanceof Error
? error.message
: (error as { message?: string })?.message
? (error as { message: string }).message
: String(error);
toast.error(`CLI 会话创建失败 (${selectedTool})`, message);
} finally {
setIsCreating(false);
}
}, [projectPath, createSessionAndAssign, selectedTool, launchMode, getOrCreateFocusedPane]);
}, [projectPath, createSessionAndAssign, selectedTool, selectedShell, launchMode, getOrCreateFocusedPane]);
const handleConfigure = useCallback(() => {
setIsConfigOpen(true);
@@ -164,7 +180,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
const targetPaneId = getOrCreateFocusedPane();
if (!targetPaneId) throw new Error('Failed to create pane');
const created = await createSessionAndAssign(
await createSessionAndAssign(
targetPaneId,
{
workingDir: config.workingDir || projectPath,
@@ -175,8 +191,15 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
},
projectPath
);
if (!created?.session?.sessionKey) throw new Error('createSessionAndAssign failed');
} catch (error: unknown) {
// Handle both Error instances and ApiError-like objects
const message = error instanceof Error
? error.message
: (error as { message?: string })?.message
? (error as { message: string }).message
: String(error);
toast.error(`CLI 会话创建失败 (${config.tool})`, message);
throw error;
} finally {
setIsCreating(false);
}
@@ -249,6 +272,31 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<span>{formatMessage({ id: 'terminalDashboard.toolbar.shell' })}</span>
<span className="text-xs text-muted-foreground">
{selectedShell === 'cmd' ? 'cmd' : selectedShell === 'pwsh' ? 'pwsh' : 'bash'}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={selectedShell}
onValueChange={(v) => setSelectedShell(v as ShellKind)}
>
<DropdownMenuRadioItem value="cmd">
cmd {formatMessage({ id: 'terminalDashboard.toolbar.shellCmdDesc' })}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bash">
bash (Git Bash/WSL)
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="pwsh">
pwsh (PowerShell)
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleQuickCreate}

View File

@@ -152,7 +152,9 @@ async function fetchApi<T>(
if (contentType && contentType.includes('application/json')) {
try {
const body = await response.json();
// Check both 'message' and 'error' fields for error message
if (body.message) error.message = body.message;
else if (body.error) error.message = body.error;
if (body.code) error.code = body.code;
} catch (parseError) {
// Silently ignore JSON parse errors for non-JSON responses
@@ -6344,7 +6346,8 @@ export interface CreateCliSessionInput {
workingDir?: string;
cols?: number;
rows?: number;
preferredShell?: 'bash' | 'pwsh';
/** Shell to use for spawning CLI tools on Windows. */
preferredShell?: 'bash' | 'pwsh' | 'cmd';
tool?: string;
model?: string;
resumeKey?: string;

View File

@@ -83,6 +83,8 @@
"mode": "Mode",
"modeDefault": "Default",
"modeYolo": "Yolo",
"shell": "Shell",
"shellCmdDesc": "(Recommended for Windows)",
"quickCreate": "Quick Create",
"configure": "Configure...",
"fullscreen": "Fullscreen",

View File

@@ -21,7 +21,14 @@
"toolbar": {
"refresh": "刷新",
"clearAll": "清空所有",
"settings": "设置"
"settings": "设置",
"back": "返回",
"addExecution": "添加",
"running": "运行中",
"executions": "执行",
"executionsList": "最近执行",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏"
},
"emptyState": {
"title": "暂无 CLI 执行",

View File

@@ -83,6 +83,8 @@
"mode": "模式",
"modeDefault": "默认",
"modeYolo": "Yolo",
"shell": "Shell",
"shellCmdDesc": "(推荐 Windows)",
"quickCreate": "快速创建",
"configure": "配置...",
"fullscreen": "全屏",

View File

@@ -341,9 +341,16 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
);
return { paneId: newPaneId, session };
} catch (error) {
console.error('Failed to create CLI session:', error);
return null;
} catch (error: unknown) {
// Handle both Error instances and ApiError objects
const errorMsg = error instanceof Error
? error.message
: (error as { message?: string })?.message
? (error as { message: string }).message
: String(error);
console.error('Failed to create CLI session:', errorMsg, { config, projectPath, rawError: error });
// Re-throw with meaningful message so UI can display it
throw new Error(errorMsg);
}
},

View File

@@ -1,6 +1,6 @@
import path from 'path';
export type CliSessionShellKind = 'wsl-bash' | 'git-bash' | 'pwsh';
export type CliSessionShellKind = 'wsl-bash' | 'git-bash' | 'pwsh' | 'cmd';
export type CliSessionResumeStrategy = 'nativeResume' | 'promptConcat';

View File

@@ -34,7 +34,8 @@ export interface CreateCliSessionOptions {
workingDir: string;
cols?: number;
rows?: number;
preferredShell?: 'bash' | 'pwsh';
/** Shell to use for spawning CLI tools on Windows. */
preferredShell?: 'bash' | 'pwsh' | 'cmd';
tool?: string;
model?: string;
resumeKey?: string;
@@ -224,10 +225,59 @@ export class CliSessionManager {
// Native CLI interactive session: spawn the CLI process directly
const launchMode = options.launchMode ?? 'default';
const config = getLaunchConfig(options.tool, launchMode);
shellKind = 'git-bash'; // PTY shell kind label (not actually a shell)
file = config.command;
args = config.args;
cliTool = options.tool;
// Build the full command string with arguments
const fullCommand = config.args.length > 0
? `${config.command} ${config.args.join(' ')}`
: config.command;
// On Windows, CLI tools installed via npm are typically .cmd files.
// node-pty cannot spawn .cmd files directly, so we need a shell wrapper.
// On Unix systems, direct spawn usually works.
if (os.platform() === 'win32') {
// Use user's preferred shell (default to cmd for reliability)
const shell = options.preferredShell ?? 'cmd';
if (shell === 'cmd') {
shellKind = 'cmd';
file = 'cmd.exe';
args = ['/c', fullCommand];
} else if (shell === 'pwsh') {
shellKind = 'pwsh';
// Check for PowerShell Core (pwsh) or fall back to Windows PowerShell
const pwshPath = spawnSync('where', ['pwsh'], { encoding: 'utf8', windowsHide: true });
if (pwshPath.status === 0) {
file = 'pwsh';
} else {
file = 'powershell';
}
args = ['-NoLogo', '-Command', fullCommand];
} else {
// bash - try git-bash or WSL
const gitBash = findGitBashExe();
if (gitBash) {
shellKind = 'git-bash';
file = gitBash;
args = ['-l', '-i', '-c', fullCommand];
} else if (isWslAvailable()) {
shellKind = 'wsl-bash';
file = 'wsl.exe';
args = ['-e', 'bash', '-l', '-i', '-c', fullCommand];
} else {
// Fall back to cmd if no bash available
shellKind = 'cmd';
file = 'cmd.exe';
args = ['/c', fullCommand];
}
}
} else {
// Unix: direct spawn works for most CLI tools
shellKind = 'git-bash';
file = config.command;
args = config.args;
}
} else {
// Legacy shell session: spawn bash/pwsh
const preferredShell = options.preferredShell ?? 'bash';
@@ -237,13 +287,21 @@ export class CliSessionManager {
args = picked.args;
}
const pty = nodePty.spawn(file, args, {
name: 'xterm-256color',
cols: options.cols ?? 120,
rows: options.rows ?? 30,
cwd: workingDir,
env: process.env as Record<string, string>
});
let pty: nodePty.IPty;
try {
pty = nodePty.spawn(file, args, {
name: 'xterm-256color',
cols: options.cols ?? 120,
rows: options.rows ?? 30,
cwd: workingDir,
env: process.env as Record<string, string>
});
} catch (spawnError: unknown) {
const errorMsg = spawnError instanceof Error ? spawnError.message : String(spawnError);
const toolInfo = options.tool ? `tool '${options.tool}' (` : '';
const shellInfo = options.tool ? `)` : `shell '${file}'`;
throw new Error(`Failed to spawn ${toolInfo}${shellInfo}: ${errorMsg}. Ensure the CLI tool is installed and available in PATH.`);
}
const session: CliSessionInternal = {
sessionKey,