Skip to content

Commit d6bf941

Browse files
author
catlog22
committed
feat: 增加对 Windows CLI 工具的支持,允许使用 cmd 作为首选 shell,并改进错误处理
1 parent 113d0bd commit d6bf941

10 files changed

Lines changed: 174 additions & 28 deletions

File tree

ccw/bin/ccw-mcp.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,19 @@
44
* Entry point for running CCW tools as an MCP server
55
*/
66

7-
import '../dist/mcp-server/index.js';
7+
// IMPORTANT:
8+
// MCP stdio servers must not write arbitrary text to stdout.
9+
// stdout is reserved for JSON-RPC protocol messages.
10+
// Redirect common console output to stderr to avoid breaking handshake.
11+
const toStderr = (...args) => console.error(...args);
12+
console.log = toStderr;
13+
console.info = toStderr;
14+
console.debug = toStderr;
15+
console.dir = toStderr;
16+
17+
try {
18+
await import('../dist/mcp-server/index.js');
19+
} catch (err) {
20+
console.error('[ccw-mcp] Failed to start MCP server:', err);
21+
process.exit(1);
22+
}

ccw/frontend/src/components/terminal-dashboard/CliConfigModal.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
2929

3030
export type CliTool = 'claude' | 'gemini' | 'qwen' | 'codex' | 'opencode';
3131
export type LaunchMode = 'default' | 'yolo';
32-
export type ShellKind = 'bash' | 'pwsh';
32+
export type ShellKind = 'bash' | 'pwsh' | 'cmd';
3333

3434
export interface CliSessionConfig {
3535
tool: CliTool;
@@ -69,7 +69,10 @@ export function CliConfigModal({
6969
const [tool, setTool] = React.useState<CliTool>('gemini');
7070
const [model, setModel] = React.useState<string | undefined>(MODEL_OPTIONS.gemini[0]);
7171
const [launchMode, setLaunchMode] = React.useState<LaunchMode>('yolo');
72-
const [preferredShell, setPreferredShell] = React.useState<ShellKind>('bash');
72+
// Default to 'cmd' on Windows for better compatibility with npm CLI tools (.cmd files)
73+
const [preferredShell, setPreferredShell] = React.useState<ShellKind>(
74+
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win') ? 'cmd' : 'bash'
75+
);
7376
const [workingDir, setWorkingDir] = React.useState<string>(defaultWorkingDir ?? '');
7477

7578
const [isSubmitting, setIsSubmitting] = React.useState(false);
@@ -216,8 +219,9 @@ export function CliConfigModal({
216219
<SelectValue />
217220
</SelectTrigger>
218221
<SelectContent>
219-
<SelectItem value="bash">bash</SelectItem>
220-
<SelectItem value="pwsh">pwsh</SelectItem>
222+
<SelectItem value="cmd">cmd (推荐 Windows)</SelectItem>
223+
<SelectItem value="bash">bash (Git Bash/WSL)</SelectItem>
224+
<SelectItem value="pwsh">pwsh (PowerShell)</SelectItem>
221225
</SelectContent>
222226
</Select>
223227
</div>

ccw/frontend/src/components/terminal-dashboard/DashboardToolbar.tsx

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
import { useIssues, useIssueQueue } from '@/hooks/useIssues';
4747
import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/terminalGridStore';
4848
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
49+
import { toast } from '@/stores/notificationStore';
4950
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
5051

5152
// ========== Types ==========
@@ -79,6 +80,7 @@ const LAYOUT_PRESETS = [
7980
];
8081

8182
type LaunchMode = 'default' | 'yolo';
83+
type ShellKind = 'bash' | 'pwsh' | 'cmd';
8284

8385
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
8486
type CliTool = (typeof CLI_TOOLS)[number];
@@ -124,6 +126,9 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
124126
const [isCreating, setIsCreating] = useState(false);
125127
const [selectedTool, setSelectedTool] = useState<CliTool>('gemini');
126128
const [launchMode, setLaunchMode] = useState<LaunchMode>('yolo');
129+
const [selectedShell, setSelectedShell] = useState<ShellKind>(
130+
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('win') ? 'cmd' : 'bash'
131+
);
127132
const [isConfigOpen, setIsConfigOpen] = useState(false);
128133

129134
// Helper to get or create a focused pane
@@ -140,18 +145,29 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
140145
setIsCreating(true);
141146
try {
142147
const targetPaneId = getOrCreateFocusedPane();
143-
if (!targetPaneId) return;
148+
if (!targetPaneId) {
149+
toast.error('无法创建会话', '未能获取或创建窗格');
150+
return;
151+
}
144152

145153
await createSessionAndAssign(targetPaneId, {
146154
workingDir: projectPath,
147-
preferredShell: 'bash',
155+
preferredShell: selectedShell,
148156
tool: selectedTool,
149157
launchMode,
150158
}, projectPath);
159+
} catch (error: unknown) {
160+
// Handle both Error instances and ApiError-like objects
161+
const message = error instanceof Error
162+
? error.message
163+
: (error as { message?: string })?.message
164+
? (error as { message: string }).message
165+
: String(error);
166+
toast.error(`CLI 会话创建失败 (${selectedTool})`, message);
151167
} finally {
152168
setIsCreating(false);
153169
}
154-
}, [projectPath, createSessionAndAssign, selectedTool, launchMode, getOrCreateFocusedPane]);
170+
}, [projectPath, createSessionAndAssign, selectedTool, selectedShell, launchMode, getOrCreateFocusedPane]);
155171

156172
const handleConfigure = useCallback(() => {
157173
setIsConfigOpen(true);
@@ -164,7 +180,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
164180
const targetPaneId = getOrCreateFocusedPane();
165181
if (!targetPaneId) throw new Error('Failed to create pane');
166182

167-
const created = await createSessionAndAssign(
183+
await createSessionAndAssign(
168184
targetPaneId,
169185
{
170186
workingDir: config.workingDir || projectPath,
@@ -175,8 +191,15 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
175191
},
176192
projectPath
177193
);
178-
179-
if (!created?.session?.sessionKey) throw new Error('createSessionAndAssign failed');
194+
} catch (error: unknown) {
195+
// Handle both Error instances and ApiError-like objects
196+
const message = error instanceof Error
197+
? error.message
198+
: (error as { message?: string })?.message
199+
? (error as { message: string }).message
200+
: String(error);
201+
toast.error(`CLI 会话创建失败 (${config.tool})`, message);
202+
throw error;
180203
} finally {
181204
setIsCreating(false);
182205
}
@@ -249,6 +272,31 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
249272
</DropdownMenuSubContent>
250273
</DropdownMenuSub>
251274

275+
<DropdownMenuSub>
276+
<DropdownMenuSubTrigger className="gap-2">
277+
<span>{formatMessage({ id: 'terminalDashboard.toolbar.shell' })}</span>
278+
<span className="text-xs text-muted-foreground">
279+
{selectedShell === 'cmd' ? 'cmd' : selectedShell === 'pwsh' ? 'pwsh' : 'bash'}
280+
</span>
281+
</DropdownMenuSubTrigger>
282+
<DropdownMenuSubContent>
283+
<DropdownMenuRadioGroup
284+
value={selectedShell}
285+
onValueChange={(v) => setSelectedShell(v as ShellKind)}
286+
>
287+
<DropdownMenuRadioItem value="cmd">
288+
cmd {formatMessage({ id: 'terminalDashboard.toolbar.shellCmdDesc' })}
289+
</DropdownMenuRadioItem>
290+
<DropdownMenuRadioItem value="bash">
291+
bash (Git Bash/WSL)
292+
</DropdownMenuRadioItem>
293+
<DropdownMenuRadioItem value="pwsh">
294+
pwsh (PowerShell)
295+
</DropdownMenuRadioItem>
296+
</DropdownMenuRadioGroup>
297+
</DropdownMenuSubContent>
298+
</DropdownMenuSub>
299+
252300
<DropdownMenuSeparator />
253301
<DropdownMenuItem
254302
onClick={handleQuickCreate}

ccw/frontend/src/lib/api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ async function fetchApi<T>(
152152
if (contentType && contentType.includes('application/json')) {
153153
try {
154154
const body = await response.json();
155+
// Check both 'message' and 'error' fields for error message
155156
if (body.message) error.message = body.message;
157+
else if (body.error) error.message = body.error;
156158
if (body.code) error.code = body.code;
157159
} catch (parseError) {
158160
// Silently ignore JSON parse errors for non-JSON responses
@@ -6344,7 +6346,8 @@ export interface CreateCliSessionInput {
63446346
workingDir?: string;
63456347
cols?: number;
63466348
rows?: number;
6347-
preferredShell?: 'bash' | 'pwsh';
6349+
/** Shell to use for spawning CLI tools on Windows. */
6350+
preferredShell?: 'bash' | 'pwsh' | 'cmd';
63486351
tool?: string;
63496352
model?: string;
63506353
resumeKey?: string;

ccw/frontend/src/locales/en/terminal-dashboard.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@
8383
"mode": "Mode",
8484
"modeDefault": "Default",
8585
"modeYolo": "Yolo",
86+
"shell": "Shell",
87+
"shellCmdDesc": "(Recommended for Windows)",
8688
"quickCreate": "Quick Create",
8789
"configure": "Configure...",
8890
"fullscreen": "Fullscreen",

ccw/frontend/src/locales/zh/cli-viewer.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@
2121
"toolbar": {
2222
"refresh": "刷新",
2323
"clearAll": "清空所有",
24-
"settings": "设置"
24+
"settings": "设置",
25+
"back": "返回",
26+
"addExecution": "添加",
27+
"running": "运行中",
28+
"executions": "执行",
29+
"executionsList": "最近执行",
30+
"fullscreen": "全屏",
31+
"exitFullscreen": "退出全屏"
2532
},
2633
"emptyState": {
2734
"title": "暂无 CLI 执行",

ccw/frontend/src/locales/zh/terminal-dashboard.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@
8383
"mode": "模式",
8484
"modeDefault": "默认",
8585
"modeYolo": "Yolo",
86+
"shell": "Shell",
87+
"shellCmdDesc": "(推荐 Windows)",
8688
"quickCreate": "快速创建",
8789
"configure": "配置...",
8890
"fullscreen": "全屏",

ccw/frontend/src/stores/terminalGridStore.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -341,9 +341,16 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
341341
);
342342

343343
return { paneId: newPaneId, session };
344-
} catch (error) {
345-
console.error('Failed to create CLI session:', error);
346-
return null;
344+
} catch (error: unknown) {
345+
// Handle both Error instances and ApiError objects
346+
const errorMsg = error instanceof Error
347+
? error.message
348+
: (error as { message?: string })?.message
349+
? (error as { message: string }).message
350+
: String(error);
351+
console.error('Failed to create CLI session:', errorMsg, { config, projectPath, rawError: error });
352+
// Re-throw with meaningful message so UI can display it
353+
throw new Error(errorMsg);
347354
}
348355
},
349356

ccw/src/core/services/cli-session-command-builder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import path from 'path';
22

3-
export type CliSessionShellKind = 'wsl-bash' | 'git-bash' | 'pwsh';
3+
export type CliSessionShellKind = 'wsl-bash' | 'git-bash' | 'pwsh' | 'cmd';
44

55
export type CliSessionResumeStrategy = 'nativeResume' | 'promptConcat';
66

ccw/src/core/services/cli-session-manager.ts

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ export interface CreateCliSessionOptions {
3434
workingDir: string;
3535
cols?: number;
3636
rows?: number;
37-
preferredShell?: 'bash' | 'pwsh';
37+
/** Shell to use for spawning CLI tools on Windows. */
38+
preferredShell?: 'bash' | 'pwsh' | 'cmd';
3839
tool?: string;
3940
model?: string;
4041
resumeKey?: string;
@@ -224,10 +225,59 @@ export class CliSessionManager {
224225
// Native CLI interactive session: spawn the CLI process directly
225226
const launchMode = options.launchMode ?? 'default';
226227
const config = getLaunchConfig(options.tool, launchMode);
227-
shellKind = 'git-bash'; // PTY shell kind label (not actually a shell)
228-
file = config.command;
229-
args = config.args;
230228
cliTool = options.tool;
229+
230+
// Build the full command string with arguments
231+
const fullCommand = config.args.length > 0
232+
? `${config.command} ${config.args.join(' ')}`
233+
: config.command;
234+
235+
// On Windows, CLI tools installed via npm are typically .cmd files.
236+
// node-pty cannot spawn .cmd files directly, so we need a shell wrapper.
237+
// On Unix systems, direct spawn usually works.
238+
if (os.platform() === 'win32') {
239+
// Use user's preferred shell (default to cmd for reliability)
240+
const shell = options.preferredShell ?? 'cmd';
241+
242+
if (shell === 'cmd') {
243+
shellKind = 'cmd';
244+
file = 'cmd.exe';
245+
args = ['/c', fullCommand];
246+
} else if (shell === 'pwsh') {
247+
shellKind = 'pwsh';
248+
// Check for PowerShell Core (pwsh) or fall back to Windows PowerShell
249+
const pwshPath = spawnSync('where', ['pwsh'], { encoding: 'utf8', windowsHide: true });
250+
if (pwshPath.status === 0) {
251+
file = 'pwsh';
252+
} else {
253+
file = 'powershell';
254+
}
255+
args = ['-NoLogo', '-Command', fullCommand];
256+
} else {
257+
// bash - try git-bash or WSL
258+
const gitBash = findGitBashExe();
259+
if (gitBash) {
260+
shellKind = 'git-bash';
261+
file = gitBash;
262+
args = ['-l', '-i', '-c', fullCommand];
263+
} else if (isWslAvailable()) {
264+
shellKind = 'wsl-bash';
265+
file = 'wsl.exe';
266+
args = ['-e', 'bash', '-l', '-i', '-c', fullCommand];
267+
} else {
268+
// Fall back to cmd if no bash available
269+
shellKind = 'cmd';
270+
file = 'cmd.exe';
271+
args = ['/c', fullCommand];
272+
}
273+
}
274+
} else {
275+
// Unix: direct spawn works for most CLI tools
276+
shellKind = 'git-bash';
277+
file = config.command;
278+
args = config.args;
279+
}
280+
231281
} else {
232282
// Legacy shell session: spawn bash/pwsh
233283
const preferredShell = options.preferredShell ?? 'bash';
@@ -237,13 +287,21 @@ export class CliSessionManager {
237287
args = picked.args;
238288
}
239289

240-
const pty = nodePty.spawn(file, args, {
241-
name: 'xterm-256color',
242-
cols: options.cols ?? 120,
243-
rows: options.rows ?? 30,
244-
cwd: workingDir,
245-
env: process.env as Record<string, string>
246-
});
290+
let pty: nodePty.IPty;
291+
try {
292+
pty = nodePty.spawn(file, args, {
293+
name: 'xterm-256color',
294+
cols: options.cols ?? 120,
295+
rows: options.rows ?? 30,
296+
cwd: workingDir,
297+
env: process.env as Record<string, string>
298+
});
299+
} catch (spawnError: unknown) {
300+
const errorMsg = spawnError instanceof Error ? spawnError.message : String(spawnError);
301+
const toolInfo = options.tool ? `tool '${options.tool}' (` : '';
302+
const shellInfo = options.tool ? `)` : `shell '${file}'`;
303+
throw new Error(`Failed to spawn ${toolInfo}${shellInfo}: ${errorMsg}. Ensure the CLI tool is installed and available in PATH.`);
304+
}
247305

248306
const session: CliSessionInternal = {
249307
sessionKey,

0 commit comments

Comments
 (0)