feat: Implement Cross-CLI Sync Panel for MCP servers

- Added CrossCliSyncPanel component for synchronizing MCP servers between Claude and Codex.
- Implemented server selection, copy operations, and result handling.
- Added tests for path mapping on Windows drives.
- Created E2E tests for ask_question Answer Broker functionality.
- Introduced MCP Tools Test Script for validating modified read_file and edit_file tools.
- Updated path_mapper to ensure correct drive formatting on Windows.
- Added .gitignore for ace-tool directory.
This commit is contained in:
catlog22
2026-02-08 23:19:19 +08:00
parent b9b2932f50
commit dfe153778c
24 changed files with 1911 additions and 168 deletions

View File

@@ -3,7 +3,7 @@
// ========================================
// Table component displaying all recent projects with MCP server statistics
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Folder, Clock, Database, ExternalLink } from 'lucide-react';
import { Card } from '@/components/ui/Card';
@@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/Badge';
import { useProjectOperations } from '@/hooks';
import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
import { fetchOtherProjectsServers } from '@/lib/api';
// ========== Types ==========
@@ -32,6 +33,8 @@ export interface AllProjectsTableProps {
className?: string;
/** Maximum number of projects to display */
maxProjects?: number;
/** Project paths to display (if not provided, fetches from useProjectOperations) */
projectPaths?: string[];
}
// ========== Component ==========
@@ -41,29 +44,65 @@ export function AllProjectsTable({
onOpenNewWindow,
className,
maxProjects,
projectPaths: propProjectPaths,
}: AllProjectsTableProps) {
const { formatMessage } = useIntl();
const [sortField, setSortField] = useState<'name' | 'serverCount' | 'lastModified'>('lastModified');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [projectStats, setProjectStats] = useState<ProjectServerStats[]>([]);
const [isStatsLoading, setIsStatsLoading] = useState(false);
const { projects, currentProject, isLoading } = useProjectOperations();
// Mock server counts since backend doesn't provide per-project stats
// In production, this would come from a dedicated API endpoint
const projectStats: ProjectServerStats[] = projects.slice(0, maxProjects).map((path) => {
const isCurrent = path === currentProject;
// Extract name from path (last segment)
const name = path.split(/[/\\]/).filter(Boolean).pop() || path;
// Use provided project paths or default to all projects
const targetProjectPaths = propProjectPaths ?? projects;
const displayProjects = maxProjects ? targetProjectPaths.slice(0, maxProjects) : targetProjectPaths;
return {
name,
path,
serverCount: Math.floor(Math.random() * 10), // Mock data
enabledCount: Math.floor(Math.random() * 8), // Mock data
lastModified: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
isCurrent,
// Fetch real project server stats on mount
useEffect(() => {
const fetchStats = async () => {
if (displayProjects.length === 0) {
setProjectStats([]);
return;
}
setIsStatsLoading(true);
try {
const response = await fetchOtherProjectsServers(displayProjects);
const stats: ProjectServerStats[] = displayProjects.map((path) => {
const isCurrent = path === currentProject;
const name = path.split(/[/\\]/).filter(Boolean).pop() || path;
const servers = response.servers[path] ?? [];
return {
name,
path,
serverCount: servers.length,
enabledCount: servers.filter((s) => s.enabled).length,
lastModified: undefined, // Backend doesn't provide this yet
isCurrent,
};
});
setProjectStats(stats);
} catch (error) {
console.error('Failed to fetch project server stats:', error);
// Fallback to empty stats on error
setProjectStats(
displayProjects.map((path) => ({
name: path.split(/[/\\]/).filter(Boolean).pop() || path,
path,
serverCount: 0,
enabledCount: 0,
isCurrent: path === currentProject,
}))
);
} finally {
setIsStatsLoading(false);
}
};
});
void fetchStats();
}, [displayProjects, currentProject]);
// Sort projects
const sortedProjects = [...projectStats].sort((a, b) => {
@@ -107,7 +146,7 @@ export function AllProjectsTable({
onOpenNewWindow?.(projectPath);
};
if (isLoading) {
if (isLoading || isStatsLoading) {
return (
<Card className={cn('p-8', className)}>
<div className="flex items-center justify-center">

View File

@@ -30,6 +30,9 @@ import {
installCcwMcp,
uninstallCcwMcp,
updateCcwConfig,
installCcwMcpToCodex,
uninstallCcwMcpFromCodex,
updateCcwConfigForCodex,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { useQueryClient } from '@tanstack/react-query';
@@ -77,6 +80,8 @@ export interface CcwToolsMcpCardProps {
onUpdateConfig: (config: Partial<CcwConfig>) => void;
/** Callback when install/uninstall is triggered */
onInstall: () => void;
/** Installation target: Claude or Codex */
target?: 'claude' | 'codex';
}
// ========== Constants ==========
@@ -105,6 +110,7 @@ export function CcwToolsMcpCard({
onToggleTool,
onUpdateConfig,
onInstall,
target = 'claude',
}: CcwToolsMcpCardProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
@@ -117,22 +123,36 @@ export function CcwToolsMcpCard({
const [isExpanded, setIsExpanded] = useState(false);
const [installScope, setInstallScope] = useState<'global' | 'project'>('global');
const isCodex = target === 'codex';
// Mutations for install/uninstall
const installMutation = useMutation({
mutationFn: (params: { scope: 'global' | 'project'; projectPath?: string }) =>
installCcwMcp(params.scope, params.projectPath),
mutationFn: isCodex
? () => installCcwMcpToCodex()
: (params: { scope: 'global' | 'project'; projectPath?: string }) =>
installCcwMcp(params.scope, params.projectPath),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
if (isCodex) {
queryClient.invalidateQueries({ queryKey: ['codexMcpServers'] });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfigCodex'] });
} else {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
}
onInstall();
},
});
const uninstallMutation = useMutation({
mutationFn: uninstallCcwMcp,
mutationFn: isCodex ? uninstallCcwMcpFromCodex : uninstallCcwMcp,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
if (isCodex) {
queryClient.invalidateQueries({ queryKey: ['codexMcpServers'] });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfigCodex'] });
} else {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
}
onInstall();
},
onError: (error) => {
@@ -141,9 +161,13 @@ export function CcwToolsMcpCard({
});
const updateConfigMutation = useMutation({
mutationFn: updateCcwConfig,
mutationFn: isCodex ? updateCcwConfigForCodex : updateCcwConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
if (isCodex) {
queryClient.invalidateQueries({ queryKey: ['codexMcpServers'] });
} else {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
}
},
});
@@ -170,10 +194,14 @@ export function CcwToolsMcpCard({
};
const handleInstallClick = () => {
installMutation.mutate({
scope: installScope,
projectPath: installScope === 'project' ? currentProjectPath : undefined,
});
if (isCodex) {
(installMutation as any).mutate(undefined);
} else {
(installMutation as any).mutate({
scope: installScope,
projectPath: installScope === 'project' ? currentProjectPath : undefined,
});
}
};
const handleUninstallClick = () => {
@@ -213,6 +241,11 @@ export function CcwToolsMcpCard({
<Badge variant={isInstalled ? 'default' : 'secondary'} className="text-xs">
{isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })}
</Badge>
{isCodex && (
<Badge variant="outline" className="text-xs text-blue-500">
Codex
</Badge>
)}
{isInstalled && (
<Badge variant="outline" className="text-xs text-info">
{formatMessage({ id: 'mcp.ccw.status.special' })}
@@ -388,8 +421,8 @@ export function CcwToolsMcpCard({
{/* Install/Uninstall Button */}
<div className="pt-3 border-t border-border space-y-3">
{/* Scope Selection */}
{!isInstalled && (
{/* Scope Selection - Claude only (Codex is always global) */}
{!isInstalled && !isCodex && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase">
{formatMessage({ id: 'mcp.scope' })}
@@ -422,6 +455,12 @@ export function CcwToolsMcpCard({
</div>
</div>
)}
{/* Codex note */}
{isCodex && !isInstalled && (
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.ccw.codexNote' })}
</p>
)}
{!isInstalled ? (
<Button
onClick={handleInstallClick}
@@ -430,7 +469,7 @@ export function CcwToolsMcpCard({
>
{isPending
? formatMessage({ id: 'mcp.ccw.actions.installing' })
: formatMessage({ id: 'mcp.ccw.actions.install' })
: formatMessage({ id: isCodex ? 'mcp.ccw.actions.installCodex' : 'mcp.ccw.actions.install' })
}
</Button>
) : (

View File

@@ -0,0 +1,510 @@
// ========================================
// Cross-CLI Sync Panel Component
// ========================================
// Inline panel for synchronizing MCP servers between Claude and Codex
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { ArrowRight, ArrowLeft, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { Checkbox } from '@/components/ui/Checkbox';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { useMcpServers } from '@/hooks';
import { crossCliCopy, fetchCodexMcpServers } from '@/lib/api';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
// ========== Types ==========
export interface CrossCliSyncPanelProps {
/** Callback when copy is successful */
onSuccess?: (copiedCount: number, direction: 'to-codex' | 'from-codex') => void;
/** Additional class name */
className?: string;
}
interface ServerCheckboxItem {
name: string;
command: string;
enabled: boolean;
selected: boolean;
}
type CopyDirection = 'to-codex' | 'from-codex';
// ========== Component ==========
export function CrossCliSyncPanel({ onSuccess, className }: CrossCliSyncPanelProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
// Claude servers state
const { servers: claudeServers } = useMcpServers();
const [selectedClaude, setSelectedClaude] = useState<Set<string>>(new Set());
// Codex servers state
const [codexServers, setCodexServers] = useState<ServerCheckboxItem[]>([]);
const [selectedCodex, setSelectedCodex] = useState<Set<string>>(new Set());
const [isLoadingCodex, setIsLoadingCodex] = useState(false);
const [codexError, setCodexError] = useState<string | null>(null);
// Copy operation state
const [isCopying, setIsCopying] = useState(false);
const [copyResult, setCopyResult] = useState<{
type: 'success' | 'partial' | null;
copied: number;
failed: number;
}>({ type: null, copied: 0, failed: 0 });
// Load Codex servers on mount
useEffect(() => {
const loadCodexServers = async () => {
setIsLoadingCodex(true);
setCodexError(null);
try {
const codex = await fetchCodexMcpServers();
setCodexServers(
(codex.servers ?? []).map((s) => ({
name: s.name,
command: s.command,
enabled: s.enabled,
selected: false,
}))
);
} catch (error) {
console.error('Failed to load Codex MCP servers:', error);
setCodexError(formatMessage({ id: 'mcp.sync.codexLoadError' }));
setCodexServers([]);
} finally {
setIsLoadingCodex(false);
}
};
void loadCodexServers();
}, [formatMessage]);
// Claude server handlers
const toggleClaudeServer = (name: string) => {
setSelectedClaude((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const selectAllClaude = () => {
setSelectedClaude(new Set(claudeServers.map((s) => s.name)));
};
const clearAllClaude = () => {
setSelectedClaude(new Set());
};
// Codex server handlers
const toggleCodexServer = (name: string) => {
setSelectedCodex((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
setCodexServers((prev) =>
prev.map((s) => (s.name === name ? { ...s, selected: !s.selected } : s))
);
};
const selectAllCodex = () => {
const allNames = codexServers.map((s) => s.name);
setSelectedCodex(new Set(allNames));
setCodexServers((prev) => prev.map((s) => ({ ...s, selected: true })));
};
const clearAllCodex = () => {
setSelectedCodex(new Set());
setCodexServers((prev) => prev.map((s) => ({ ...s, selected: false })));
};
// Copy handlers
const handleCopyToCodex = async () => {
if (selectedClaude.size === 0) return;
setIsCopying(true);
setCopyResult({ type: null, copied: 0, failed: 0 });
try {
const result = await crossCliCopy({
source: 'claude',
target: 'codex',
serverNames: Array.from(selectedClaude),
projectPath: projectPath ?? undefined,
});
if (result.success) {
const failedCount = result.failed.length;
const copiedCount = result.copied.length;
setCopyResult({
type: failedCount > 0 ? 'partial' : 'success',
copied: copiedCount,
failed: failedCount,
});
onSuccess?.(copiedCount, 'to-codex');
// Clear selection after successful copy
setSelectedClaude(new Set());
// Auto-hide result after 3 seconds
setTimeout(() => {
setCopyResult({ type: null, copied: 0, failed: 0 });
}, 3000);
}
} catch (error) {
console.error('Failed to copy to Codex:', error);
setCopyResult({ type: 'partial', copied: 0, failed: selectedClaude.size });
} finally {
setIsCopying(false);
}
};
const handleCopyFromCodex = async () => {
if (selectedCodex.size === 0 || !projectPath) {
return;
}
setIsCopying(true);
setCopyResult({ type: null, copied: 0, failed: 0 });
try {
const result = await crossCliCopy({
source: 'codex',
target: 'claude',
serverNames: Array.from(selectedCodex),
projectPath,
});
if (result.success) {
const failedCount = result.failed.length;
const copiedCount = result.copied.length;
setCopyResult({
type: failedCount > 0 ? 'partial' : 'success',
copied: copiedCount,
failed: failedCount,
});
onSuccess?.(copiedCount, 'from-codex');
// Clear selection after successful copy
setSelectedCodex(new Set());
setCodexServers((prev) => prev.map((s) => ({ ...s, selected: false })));
// Auto-hide result after 3 seconds
setTimeout(() => {
setCopyResult({ type: null, copied: 0, failed: 0 });
}, 3000);
}
} catch (error) {
console.error('Failed to copy from Codex:', error);
setCopyResult({ type: 'partial', copied: 0, failed: selectedCodex.size });
} finally {
setIsCopying(false);
}
};
// Computed values
const claudeTotal = claudeServers.length;
const claudeSelected = selectedClaude.size;
const codexTotal = codexServers.length;
const codexSelected = selectedCodex.size;
return (
<div className={cn('space-y-4', className)}>
{/* Header */}
<div className="text-center">
<h3 className="text-base font-semibold text-foreground">
{formatMessage({ id: 'mcp.sync.title' })}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{formatMessage({ id: 'mcp.sync.description' })}
</p>
</div>
{/* Result Message */}
{copyResult.type !== null && (
<div
className={cn(
'flex items-center gap-2 p-3 rounded-lg border',
copyResult.type === 'success'
? 'bg-success/10 border-success/30 text-success'
: 'bg-warning/10 border-warning/30 text-warning'
)}
>
{copyResult.type === 'success' ? (
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 flex-shrink-0" />
)}
<span className="text-sm">
{copyResult.type === 'success'
? formatMessage(
{ id: 'mcp.sync.copySuccess' },
{ count: copyResult.copied }
)
: formatMessage(
{ id: 'mcp.sync.copyPartial' },
{ copied: copyResult.copied, failed: copyResult.failed }
)}
</span>
</div>
)}
{/* Two-column layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Claude Column */}
<div className="border border-border rounded-lg overflow-hidden">
{/* Column Header */}
<div className="bg-muted/50 px-4 py-3 border-b border-border">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.sync.claudeColumn' })}
</h4>
<Badge variant="outline" className="text-xs">
{formatMessage(
{ id: 'mcp.sync.selectedCount' },
{ count: claudeSelected, total: claudeTotal }
)}
</Badge>
</div>
</div>
{/* Claude Server List */}
<div className="p-2 max-h-64 overflow-y-auto">
{claudeTotal === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{formatMessage({ id: 'mcp.sync.noServers' })}
</div>
) : (
<div className="space-y-1">
{claudeServers.map((server) => (
<div
key={server.name}
className={cn(
'flex items-start gap-2 p-2 rounded cursor-pointer transition-colors',
selectedClaude.has(server.name)
? 'bg-primary/10'
: 'hover:bg-muted/50'
)}
onClick={() => toggleClaudeServer(server.name)}
>
<Checkbox
id={`claude-${server.name}`}
checked={selectedClaude.has(server.name)}
onChange={() => toggleClaudeServer(server.name)}
className="w-4 h-4 mt-0.5"
/>
<label
htmlFor={`claude-${server.name}`}
className="flex-1 min-w-0 cursor-pointer"
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{server.name}
</span>
{server.enabled && (
<Badge variant="success" className="text-xs">
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{server.command}
</p>
</label>
</div>
))}
</div>
)}
</div>
{/* Claude Footer Actions */}
{claudeTotal > 0 && (
<div className="px-2 py-2 bg-muted/30 border-t border-border flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={selectAllClaude}
className="flex-1 text-xs"
>
{formatMessage({ id: 'mcp.sync.selectAll' })}
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearAllClaude}
className="flex-1 text-xs"
disabled={claudeSelected === 0}
>
{formatMessage({ id: 'mcp.sync.clearAll' })}
</Button>
</div>
)}
</div>
{/* Codex Column */}
<div className="border border-border rounded-lg overflow-hidden">
{/* Column Header */}
<div className="bg-muted/50 px-4 py-3 border-b border-border">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.sync.codexColumn' })}
</h4>
<Badge variant="outline" className="text-xs">
{formatMessage(
{ id: 'mcp.sync.selectedCount' },
{ count: codexSelected, total: codexTotal }
)}
</Badge>
</div>
</div>
{/* Codex Server List */}
<div className="p-2 max-h-64 overflow-y-auto">
{isLoadingCodex ? (
<div className="py-8 flex items-center justify-center">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : codexError ? (
<div className="py-8 text-center text-sm text-destructive">
{codexError}
</div>
) : codexTotal === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{formatMessage({ id: 'mcp.sync.noServers' })}
</div>
) : (
<div className="space-y-1">
{codexServers.map((server) => (
<div
key={server.name}
className={cn(
'flex items-start gap-2 p-2 rounded cursor-pointer transition-colors',
server.selected
? 'bg-primary/10'
: 'hover:bg-muted/50'
)}
onClick={() => toggleCodexServer(server.name)}
>
<Checkbox
id={`codex-${server.name}`}
checked={server.selected}
onChange={() => toggleCodexServer(server.name)}
className="w-4 h-4 mt-0.5"
/>
<label
htmlFor={`codex-${server.name}`}
className="flex-1 min-w-0 cursor-pointer"
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{server.name}
</span>
{server.enabled && (
<Badge variant="success" className="text-xs">
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{server.command}
</p>
</label>
</div>
))}
</div>
)}
</div>
{/* Codex Footer Actions */}
{codexTotal > 0 && (
<div className="px-2 py-2 bg-muted/30 border-t border-border flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={selectAllCodex}
className="flex-1 text-xs"
>
{formatMessage({ id: 'mcp.sync.selectAll' })}
</Button>
<Button
variant="ghost"
size="sm"
onClick={clearAllCodex}
className="flex-1 text-xs"
disabled={codexSelected === 0}
>
{formatMessage({ id: 'mcp.sync.clearAll' })}
</Button>
</div>
)}
</div>
</div>
{/* Copy Buttons */}
<div className="flex items-center justify-center gap-3">
<Button
onClick={handleCopyToCodex}
disabled={claudeSelected === 0 || isCopying}
variant="default"
className="min-w-40"
>
{isCopying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'mcp.sync.syncInProgress' })}
</>
) : (
<>
<ArrowRight className="w-4 h-4 mr-2" />
{formatMessage(
{ id: 'mcp.sync.copyToCodex' },
{ count: claudeSelected }
)}
</>
)}
</Button>
<Button
onClick={handleCopyFromCodex}
disabled={codexSelected === 0 || isCopying || !projectPath}
variant="default"
className="min-w-40"
>
{isCopying ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'mcp.sync.syncInProgress' })}
</>
) : (
<>
<ArrowLeft className="w-4 h-4 mr-2" />
{formatMessage(
{ id: 'mcp.sync.copyFromCodex' },
{ count: codexSelected }
)}
</>
)}
</Button>
</div>
</div>
);
}
export default CrossCliSyncPanel;

View File

@@ -3411,6 +3411,113 @@ export async function uninstallCcwMcp(): Promise<void> {
}
}
// ========== CCW Tools MCP - Codex API ==========
/**
* Fetch CCW Tools MCP configuration from Codex config.toml
*/
export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
try {
const { servers } = await fetchCodexMcpServers();
const ccwServer = servers.find((s) => s.name === 'ccw-tools');
if (!ccwServer) {
return { isInstalled: false, enabledTools: [] };
}
const env = ccwServer.env || {};
const enabledToolsStr = env.CCW_ENABLED_TOOLS || 'all';
const enabledTools = enabledToolsStr === 'all'
? ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search']
: enabledToolsStr.split(',').map((t: string) => t.trim());
return {
isInstalled: true,
enabledTools,
projectRoot: env.CCW_PROJECT_ROOT,
allowedDirs: env.CCW_ALLOWED_DIRS,
disableSandbox: env.CCW_DISABLE_SANDBOX === '1',
};
} catch {
return { isInstalled: false, enabledTools: [] };
}
}
/**
* Build CCW MCP server config for Codex (uses global ccw-mcp command)
*/
function buildCcwMcpServerConfigForCodex(config: {
enabledTools?: string[];
projectRoot?: string;
allowedDirs?: string;
disableSandbox?: boolean;
}): { command: string; args: string[]; env: Record<string, string> } {
const env: Record<string, string> = {};
if (config.enabledTools && config.enabledTools.length > 0) {
env.CCW_ENABLED_TOOLS = config.enabledTools.join(',');
} else {
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
}
if (config.projectRoot) {
env.CCW_PROJECT_ROOT = config.projectRoot;
}
if (config.allowedDirs) {
env.CCW_ALLOWED_DIRS = config.allowedDirs;
}
if (config.disableSandbox) {
env.CCW_DISABLE_SANDBOX = '1';
}
return { command: 'ccw-mcp', args: [], env };
}
/**
* Install CCW Tools MCP to Codex config.toml
*/
export async function installCcwMcpToCodex(): Promise<CcwMcpConfig> {
const serverConfig = buildCcwMcpServerConfigForCodex({
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
});
const result = await addCodexMcpServer('ccw-tools', serverConfig);
if (result.error) {
throw new Error(result.error || 'Failed to install CCW MCP to Codex');
}
return fetchCcwMcpConfigForCodex();
}
/**
* Uninstall CCW Tools MCP from Codex config.toml
*/
export async function uninstallCcwMcpFromCodex(): Promise<void> {
const result = await codexRemoveServer('ccw-tools');
if (!result.success) {
throw new Error(result.error || 'Failed to uninstall CCW MCP from Codex');
}
}
/**
* Update CCW Tools MCP configuration in Codex config.toml
*/
export async function updateCcwConfigForCodex(config: {
enabledTools?: string[];
projectRoot?: string;
allowedDirs?: string;
disableSandbox?: boolean;
}): Promise<CcwMcpConfig> {
const serverConfig = buildCcwMcpServerConfigForCodex(config);
const result = await addCodexMcpServer('ccw-tools', serverConfig);
if (result.error) {
throw new Error(result.error || 'Failed to update CCW config in Codex');
}
return fetchCcwMcpConfigForCodex();
}
// ========== Index Management API ==========
/**

View File

@@ -151,13 +151,15 @@
"enableAll": "Enable All",
"disableAll": "Disable All",
"install": "Install CCW MCP",
"installCodex": "Install to Codex",
"installing": "Installing...",
"uninstall": "Uninstall",
"uninstalling": "Uninstalling...",
"uninstallConfirm": "Are you sure you want to uninstall CCW MCP?",
"saveConfig": "Save Configuration",
"saving": "Saving..."
}
},
"codexNote": "Requires: npm install -g claude-code-workflow"
},
"recommended": {
"title": "Recommended Servers",
@@ -266,6 +268,30 @@
"copying": "Copying...",
"copyButton": "Copy to {target}"
},
"sync": {
"title": "MCP Server Sync",
"description": "Synchronize MCP server configurations between Claude and Codex",
"claudeColumn": "Claude Servers",
"codexColumn": "Codex Servers",
"selectedCount": "Selected: {count} / Total: {total}",
"selectAll": "Select All",
"clearAll": "Clear All",
"copyToCodex": "→ Copy to Codex ({count})",
"copyFromCodex": "← Copy from Codex ({count})",
"noServers": "No servers available",
"codexLoadError": "Failed to load Codex servers",
"copySuccess": "Successfully copied {count} server(s)",
"copyPartial": "{copied} succeeded, {failed} failed",
"syncInProgress": "Syncing..."
},
"projects": {
"title": "Projects Overview",
"description": "View MCP server configurations for all projects"
},
"crossProject": {
"title": "Cross-Project Import",
"description": "Import MCP server configurations from other projects"
},
"allProjects": {
"title": "All Projects",
"name": "Project Name",

View File

@@ -151,13 +151,15 @@
"enableAll": "全部启用",
"disableAll": "全部禁用",
"install": "安装 CCW MCP",
"installCodex": "安装到 Codex",
"installing": "安装中...",
"uninstall": "卸载",
"uninstalling": "卸载中...",
"uninstallConfirm": "确定要卸载 CCW MCP 吗?",
"saveConfig": "保存配置",
"saving": "保存中..."
}
},
"codexNote": "需要全局安装npm install -g claude-code-workflow"
},
"recommended": {
"title": "推荐服务器",
@@ -266,6 +268,30 @@
"copying": "复制中...",
"copyButton": "复制到 {target}"
},
"sync": {
"title": "MCP 服务器同步",
"description": "在 Claude 和 Codex 之间同步 MCP 服务器配置",
"claudeColumn": "Claude 服务器",
"codexColumn": "Codex 服务器",
"selectedCount": "已选: {count} / 总计: {total}",
"selectAll": "全选",
"clearAll": "清空",
"copyToCodex": "→ 复制到 Codex ({count})",
"copyFromCodex": "← 从 Codex 复制 ({count})",
"noServers": "没有可用的服务器",
"codexLoadError": "加载 Codex 服务器失败",
"copySuccess": "成功复制 {count} 个服务器",
"copyPartial": "成功 {copied} 个,失败 {failed} 个",
"syncInProgress": "同步中..."
},
"projects": {
"title": "项目概览",
"description": "查看所有项目的 MCP 服务器配置"
},
"crossProject": {
"title": "跨项目导入",
"description": "从其他项目导入 MCP 服务器配置"
},
"allProjects": {
"title": "所有项目",
"name": "项目名称",

View File

@@ -33,7 +33,7 @@ import { CcwToolsMcpCard } from '@/components/mcp/CcwToolsMcpCard';
import { McpTemplatesSection, TemplateSaveDialog } from '@/components/mcp/McpTemplatesSection';
import { RecommendedMcpSection } from '@/components/mcp/RecommendedMcpSection';
import { WindowsCompatibilityWarning } from '@/components/mcp/WindowsCompatibilityWarning';
import { CrossCliCopyButton } from '@/components/mcp/CrossCliCopyButton';
import { CrossCliSyncPanel } from '@/components/mcp/CrossCliSyncPanel';
import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
@@ -41,7 +41,9 @@ import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks'
import {
fetchCodexMcpServers,
fetchCcwMcpConfig,
fetchCcwMcpConfigForCodex,
updateCcwConfig,
updateCcwConfigForCodex,
codexRemoveServer,
codexToggleServer,
saveMcpTemplate,
@@ -255,6 +257,14 @@ export function McpManagerPage() {
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Fetch CCW Tools MCP configuration (Codex mode only)
const ccwMcpCodexQuery = useQuery({
queryKey: ['ccwMcpConfigCodex'],
queryFn: fetchCcwMcpConfigForCodex,
enabled: cliMode === 'codex',
staleTime: 5 * 60 * 1000, // 5 minutes
});
const {
toggleServer,
deleteServer,
@@ -358,6 +368,32 @@ export function McpManagerPage() {
ccwMcpQuery.refetch();
};
// CCW MCP handlers for Codex mode
const ccwCodexConfig = ccwMcpCodexQuery.data ?? {
isInstalled: false,
enabledTools: [],
projectRoot: undefined,
allowedDirs: undefined,
disableSandbox: undefined,
};
const handleToggleCcwToolCodex = async (tool: string, enabled: boolean) => {
const updatedTools = enabled
? [...ccwCodexConfig.enabledTools, tool]
: ccwCodexConfig.enabledTools.filter((t) => t !== tool);
await updateCcwConfigForCodex({ enabledTools: updatedTools });
ccwMcpCodexQuery.refetch();
};
const handleUpdateCcwConfigCodex = async (config: Partial<CcwMcpConfig>) => {
await updateCcwConfigForCodex(config);
ccwMcpCodexQuery.refetch();
};
const handleCcwInstallCodex = () => {
ccwMcpCodexQuery.refetch();
};
// Template handlers
const handleInstallTemplate = (template: any) => {
setEditingServer({
@@ -617,7 +653,7 @@ export function McpManagerPage() {
</div>
)}
{/* CCW Tools MCP Card - Claude mode only */}
{/* CCW Tools MCP Card */}
{cliMode === 'claude' && (
<CcwToolsMcpCard
isInstalled={ccwConfig.isInstalled}
@@ -630,6 +666,19 @@ export function McpManagerPage() {
onInstall={handleCcwInstall}
/>
)}
{cliMode === 'codex' && (
<CcwToolsMcpCard
target="codex"
isInstalled={ccwCodexConfig.isInstalled}
enabledTools={ccwCodexConfig.enabledTools}
projectRoot={ccwCodexConfig.projectRoot}
allowedDirs={ccwCodexConfig.allowedDirs}
disableSandbox={ccwCodexConfig.disableSandbox}
onToggleTool={handleToggleCcwToolCodex}
onUpdateConfig={handleUpdateCcwConfigCodex}
onInstall={handleCcwInstallCodex}
/>
)}
{/* Servers List */}
{currentIsLoading ? (
@@ -680,37 +729,56 @@ export function McpManagerPage() {
{/* Tab Content: Cross-CLI */}
{activeTab === 'cross-cli' && (
<div className="mt-4 space-y-4">
{/* Cross-CLI Copy Button */}
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex-1">
<div className="mt-4 space-y-6">
{/* Section 1: Claude ↔ Codex 同步 */}
<section>
<div className="flex items-center gap-2 mb-3">
<RefreshCw className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.crossCli.title' })}
{formatMessage({ id: 'mcp.sync.title' })}
</h3>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'mcp.crossCli.selectServersHint' })}
</p>
</div>
<CrossCliCopyButton
currentMode={cliMode}
onSuccess={() => refetch()}
<Card className="p-4">
<CrossCliSyncPanel onSuccess={(count, direction) => refetch()} />
</Card>
</section>
{/* Section 2: 项目概览 */}
<section>
<div className="flex items-center gap-2 mb-3">
<Folder className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.projects.title' })}
</h3>
</div>
<p className="text-xs text-muted-foreground mb-3">
{formatMessage({ id: 'mcp.projects.description' })}
</p>
<AllProjectsTable
maxProjects={10}
onProjectClick={(path) => console.log('Open project:', path)}
onOpenNewWindow={(path) => window.open(`/?project=${encodeURIComponent(path)}`, '_blank')}
/>
</div>
</section>
{/* All Projects Table */}
<AllProjectsTable
maxProjects={10}
onProjectClick={(path) => console.log('Open project:', path)}
onOpenNewWindow={(path) => window.open(`/?project=${encodeURIComponent(path)}`, '_blank')}
/>
{/* Other Projects Section */}
<OtherProjectsSection
onImportSuccess={(serverName, sourceProject) => {
console.log('Imported server:', serverName, 'from:', sourceProject);
refetch();
}}
/>
{/* Section 3: 跨项目导入 */}
<section>
<div className="flex items-center gap-2 mb-3">
<Globe className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.crossProject.title' })}
</h3>
</div>
<p className="text-xs text-muted-foreground mb-3">
{formatMessage({ id: 'mcp.crossProject.description' })}
</p>
<OtherProjectsSection
onImportSuccess={(serverName, sourceProject) => {
console.log('Imported server:', serverName, 'from:', sourceProject);
refetch();
}}
/>
</section>
</div>
)}

1
ccw/src/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.ace-tool/

View File

@@ -222,20 +222,30 @@ export class A2UIWebSocketHandler {
});
const req = http.request({
hostname: 'localhost',
hostname: '127.0.0.1',
port: DASHBOARD_PORT,
path: '/api/hook',
method: 'POST',
timeout: 2000,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
});
// Fire-and-forget: don't keep the process alive due to an open socket
req.on('socket', (socket) => {
socket.unref();
});
req.on('error', (err) => {
console.error(`[A2UI] Failed to forward surface ${surfaceUpdate.surfaceId} to Dashboard:`, err.message);
});
req.on('timeout', () => {
req.destroy(new Error('Request timed out'));
});
req.write(body);
req.end();

View File

@@ -90,6 +90,7 @@ const LOCALHOST_PUBLIC_PATHS = [
'/api/litellm-api/providers',
'/api/litellm-api/endpoints',
'/api/health',
'/api/a2ui/answer',
];
/**

View File

@@ -562,11 +562,17 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
return;
}
const req = http.get({ hostname: '127.0.0.1', port: DASHBOARD_PORT, path: pollPath }, (res) => {
const req = http.get({ hostname: '127.0.0.1', port: DASHBOARD_PORT, path: pollPath, timeout: 2000 }, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
res.on('end', () => {
try {
if (res.statusCode && res.statusCode >= 400) {
console.error(`[A2UI-Poll] HTTP ${res.statusCode} from Dashboard (first 200 chars):`, data.slice(0, 200));
setTimeout(poll, POLL_INTERVAL_MS);
return;
}
const parsed = JSON.parse(data);
if (parsed.pending) {
// No answer yet, schedule next poll
@@ -599,6 +605,10 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
setTimeout(poll, POLL_INTERVAL_MS);
}
});
req.on('timeout', () => {
req.destroy(new Error('Request timed out'));
});
};
// Start first poll after a short delay to give the Dashboard time to receive the surface

View File

@@ -23,25 +23,64 @@ const EditItemSchema = z.object({
newText: z.string(),
});
const ParamsSchema = z.object({
// Base schema with common parameters
const BaseParamsSchema = z.object({
path: z.string().min(1, 'Path is required'),
mode: z.enum(['update', 'line']).default('update'),
dryRun: z.boolean().default(false),
// Update mode params
});
// Update mode schema
const UpdateModeSchema = BaseParamsSchema.extend({
mode: z.literal('update').default('update'),
oldText: z.string().optional(),
newText: z.string().optional(),
edits: z.array(EditItemSchema).optional(),
replaceAll: z.boolean().optional(),
// Line mode params
operation: z.enum(['insert_before', 'insert_after', 'replace', 'delete']).optional(),
line: z.number().optional(),
end_line: z.number().optional(),
replaceAll: z.boolean().default(false),
}).refine(
(data) => {
const hasSingle = data.oldText !== undefined;
const hasBatch = data.edits !== undefined;
// XOR: Only one of oldText/newText or edits should be provided
return hasSingle !== hasBatch || (!hasSingle && !hasBatch);
},
{
message: 'Use either oldText/newText or edits array, not both',
}
);
// Line mode schema
const LineModeSchema = BaseParamsSchema.extend({
mode: z.literal('line'),
operation: z.enum(['insert_before', 'insert_after', 'replace', 'delete']),
line: z.number().int().positive('Line must be a positive integer'),
end_line: z.number().int().positive().optional(),
text: z.string().optional(),
});
}).refine(
(data) => {
// text is required for insert_before, insert_after, and replace operations
if (['insert_before', 'insert_after', 'replace'].includes(data.operation)) {
return data.text !== undefined;
}
return true;
},
{
message: 'Parameter "text" is required for insert_before, insert_after, and replace operations',
}
);
// Discriminated union schema
const ParamsSchema = z.discriminatedUnion('mode', [
UpdateModeSchema,
LineModeSchema,
]);
type Params = z.infer<typeof ParamsSchema>;
type EditItem = z.infer<typeof EditItemSchema>;
// Extract specific types for each mode
type UpdateModeParams = z.infer<typeof UpdateModeSchema>;
type LineModeParams = z.infer<typeof LineModeSchema>;
interface UpdateModeResult {
content: string;
modified: boolean;
@@ -229,7 +268,7 @@ function createUnifiedDiff(original: string, modified: string, filePath: string)
* Auto-adapts line endings (CRLF/LF)
* Supports multiple edits via 'edits' array
*/
function executeUpdateMode(content: string, params: Params, filePath: string): UpdateModeResult {
function executeUpdateMode(content: string, params: UpdateModeParams, filePath: string): UpdateModeResult {
const { oldText, newText, replaceAll, edits, dryRun = false } = params;
// Detect original line ending
@@ -334,11 +373,10 @@ function executeUpdateMode(content: string, params: Params, filePath: string): U
* Mode: line - Line-based operations
* Operations: insert_before, insert_after, replace, delete
*/
function executeLineMode(content: string, params: Params): LineModeResult {
function executeLineMode(content: string, params: LineModeParams): LineModeResult {
const { operation, line, text, end_line } = params;
if (!operation) throw new Error('Parameter "operation" is required for line mode');
if (line === undefined) throw new Error('Parameter "line" is required for line mode');
// No need for additional validation - Zod schema already ensures required fields
// Detect original line ending and normalize for processing
const hasCRLF = content.includes('\r\n');
@@ -418,15 +456,30 @@ export const schema: ToolSchema = {
name: 'edit_file',
description: `Edit file using two modes: "update" for text replacement (default) and "line" for line-based operations.
Usage (update mode):
**Update Mode** (default):
- Use oldText/newText for single replacement OR edits for multiple replacements
- Parameters: oldText, newText, replaceAll, dryRun
- Cannot use line mode parameters (operation, line, end_line, text)
- Validation: oldText/newText and edits are mutually exclusive
**Line Mode**:
- Use for precise line-based operations
- Parameters: operation (insert_before/insert_after/replace/delete), line, end_line, text, dryRun
- Cannot use update mode parameters (oldText, newText, edits, replaceAll)
Usage (update mode - single replacement):
edit_file(path="f.js", oldText="old", newText="new")
Usage (update mode - multiple replacements):
edit_file(path="f.js", edits=[{oldText:"a",newText:"b"},{oldText:"c",newText:"d"}])
Usage (line mode):
edit_file(path="f.js", mode="line", operation="insert_after", line=10, text="new line")
edit_file(path="f.js", mode="line", operation="delete", line=5, end_line=8)
Options: dryRun=true (preview diff), replaceAll=true (update mode only)`,
Options: dryRun=true (preview diff), replaceAll=true (update mode only)
**Important**: Each mode only accepts its own parameters. Providing parameters from both modes will cause a validation error.`,
inputSchema: {
type: 'object',
properties: {
@@ -448,7 +501,7 @@ Options: dryRun=true (preview diff), replaceAll=true (update mode only)`,
// Update mode params
oldText: {
type: 'string',
description: '[update mode] Text to find and replace (use oldText/newText OR edits array)',
description: '[update mode] Text to find and replace. **Mutually exclusive with edits parameter** - use either oldText/newText or edits, not both.',
},
newText: {
type: 'string',
@@ -456,7 +509,7 @@ Options: dryRun=true (preview diff), replaceAll=true (update mode only)`,
},
edits: {
type: 'array',
description: '[update mode] Array of {oldText, newText} for multiple replacements',
description: '[update mode] Array of {oldText, newText} for multiple replacements. **Mutually exclusive with oldText/newText** - use either oldText/newText or edits, not both.',
items: {
type: 'object',
properties: {
@@ -474,19 +527,19 @@ Options: dryRun=true (preview diff), replaceAll=true (update mode only)`,
operation: {
type: 'string',
enum: ['insert_before', 'insert_after', 'replace', 'delete'],
description: '[line mode] Line operation type',
description: '[line mode] Line operation type. **Only valid in line mode** - cannot be combined with update mode parameters.',
},
line: {
type: 'number',
description: '[line mode] Line number (1-based)',
description: '[line mode] Line number (1-based). **Only valid in line mode** - cannot be combined with update mode parameters.',
},
end_line: {
type: 'number',
description: '[line mode] End line for range operations',
description: '[line mode] End line for range operations. **Only valid in line mode** - cannot be combined with update mode parameters.',
},
text: {
type: 'string',
description: '[line mode] Text for insert/replace operations',
description: '[line mode] Text for insert/replace operations. **Only valid in line mode** - cannot be combined with update mode parameters.',
},
},
required: ['path'],
@@ -522,21 +575,18 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
return { success: false, error: `Invalid params: ${parsed.error.message}` };
}
const { path: filePath, mode = 'update', dryRun = false } = parsed.data;
const { path: filePath, mode, dryRun } = parsed.data;
try {
const { resolvedPath, content } = await readFile(filePath);
let result: UpdateModeResult | LineModeResult;
switch (mode) {
case 'update':
result = executeUpdateMode(content, parsed.data, filePath);
break;
case 'line':
result = executeLineMode(content, parsed.data);
break;
default:
throw new Error(`Unknown mode: ${mode}. Valid modes: update, line`);
// Use discriminated union for type narrowing
if (mode === 'line') {
result = executeLineMode(content, parsed.data as LineModeParams);
} else {
// mode is 'update' (default)
result = executeUpdateMode(content, parsed.data as UpdateModeParams, filePath);
}
// Write if modified and not dry run

View File

@@ -32,6 +32,14 @@ const ParamsSchema = z.object({
maxFiles: z.number().default(MAX_FILES).describe('Max number of files to return'),
offset: z.number().min(0).optional().describe('Line offset to start reading from (0-based, for single file only)'),
limit: z.number().min(1).optional().describe('Number of lines to read (for single file only)'),
}).refine((data) => {
// Validate: offset/limit only allowed for single file mode
const hasPagination = data.offset !== undefined || data.limit !== undefined;
const isMultiple = Array.isArray(data.paths) && data.paths.length > 1;
return !(hasPagination && isMultiple);
}, {
message: 'offset/limit parameters are only supported for single file mode. Cannot use with multiple paths.',
path: ['offset', 'limit', 'paths'],
});
type Params = z.infer<typeof ParamsSchema>;
@@ -267,12 +275,12 @@ Returns compact file list with optional content. Use offset/limit for large file
},
offset: {
type: 'number',
description: 'Line offset to start reading from (0-based, for single file only)',
description: 'Line offset to start reading from (0-based). **Only for single file mode** - validation error if used with multiple paths.',
minimum: 0,
},
limit: {
type: 'number',
description: 'Number of lines to read (for single file only)',
description: 'Number of lines to read. **Only for single file mode** - validation error if used with multiple paths.',
minimum: 1,
},
},

View File

@@ -0,0 +1,271 @@
/**
* E2E: ask_question Answer Broker
*
* Verifies that when the MCP server runs as a separate stdio process (no local WS clients),
* `ask_question` forwards the surface to the Dashboard via /api/hook and later retrieves
* the user's answer via /api/a2ui/answer polling.
*/
import { after, before, describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const serverUrl = new URL('../../dist/core/server.js', import.meta.url);
serverUrl.searchParams.set('t', String(Date.now()));
interface JsonRpcRequest {
jsonrpc: string;
id: number;
method: string;
params: any;
}
interface JsonRpcResponse {
jsonrpc: string;
id: number;
result?: any;
error?: { code: number; message: string; data?: any };
}
class McpClient {
private serverProcess!: ChildProcess;
private requestId = 0;
private pendingRequests = new Map<number, { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void }>();
private env: Record<string, string | undefined>;
constructor(env: Record<string, string | undefined>) {
this.env = env;
}
async start(): Promise<void> {
const serverPath = join(__dirname, '../../bin/ccw-mcp.js');
this.serverProcess = spawn('node', [serverPath], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...this.env },
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('MCP server start timeout')), 15000);
this.serverProcess.stderr!.on('data', (data) => {
const message = data.toString();
if (message.includes('started') || message.includes('ccw-tools')) {
clearTimeout(timeout);
resolve();
}
});
this.serverProcess.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
this.serverProcess.stdout!.on('data', (data) => {
try {
const lines = data.toString().split('\n').filter((l: string) => l.trim());
for (const line of lines) {
const response: JsonRpcResponse = JSON.parse(line);
const pending = this.pendingRequests.get(response.id);
if (pending) {
this.pendingRequests.delete(response.id);
pending.resolve(response);
}
}
} catch {
// ignore parse errors
}
});
}
async call(method: string, params: any = {}, timeoutMs = 10000): Promise<JsonRpcResponse> {
const id = ++this.requestId;
const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params };
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request timeout for ${method}`));
}, timeoutMs);
this.pendingRequests.set(id, {
resolve: (response) => {
clearTimeout(timeout);
resolve(response);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
},
});
this.serverProcess.stdin!.write(JSON.stringify(request) + '\n');
});
}
stop(): void {
this.serverProcess?.kill();
}
}
function waitForWebSocketOpen(ws: WebSocket, timeoutMs = 10000): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket open timeout')), timeoutMs);
ws.addEventListener('open', () => {
clearTimeout(t);
resolve();
});
ws.addEventListener('error', () => {
clearTimeout(t);
reject(new Error('WebSocket error'));
});
});
}
function waitForA2UISurface(ws: WebSocket, timeoutMs = 15000): Promise<any> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('Timed out waiting for a2ui-surface')), timeoutMs);
const handler = (event: MessageEvent) => {
try {
const data = JSON.parse(String(event.data));
if (data?.type === 'a2ui-surface' && data?.payload?.initialState?.questionId) {
clearTimeout(t);
ws.removeEventListener('message', handler);
resolve(data);
}
} catch {
// ignore
}
};
ws.addEventListener('message', handler);
});
}
function httpRequest(options: http.RequestOptions, body?: string, timeout = 10000): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve({ status: res.statusCode || 0, body: data }));
});
req.on('error', reject);
req.setTimeout(timeout, () => {
req.destroy();
reject(new Error('Request timeout'));
});
if (body) req.write(body);
req.end();
});
}
describe('E2E: ask_question Answer Broker', async () => {
let server: http.Server;
let port: number;
let projectRoot: string;
const originalCwd = process.cwd();
let mcp: McpClient;
let ws: WebSocket;
before(async () => {
process.env.CCW_DISABLE_WARMUP = '1';
projectRoot = mkdtempSync(join(tmpdir(), 'ccw-e2e-askq-'));
process.chdir(projectRoot);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const serverMod: any = await import(serverUrl.href);
mock.method(console, 'log', () => {});
mock.method(console, 'error', () => {});
server = await serverMod.startServer({ initialPath: projectRoot, port: 0 });
const addr = server.address();
port = typeof addr === 'object' && addr ? addr.port : 0;
assert.ok(port > 0, 'Server should start on a valid port');
ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
await waitForWebSocketOpen(ws);
mcp = new McpClient({
CCW_PROJECT_ROOT: projectRoot,
CCW_ENABLED_TOOLS: 'all',
CCW_PORT: String(port),
CCW_DISABLE_WARMUP: '1',
});
await mcp.start();
// Sanity: broker endpoint should be reachable without auth from localhost
const broker = await httpRequest({ hostname: '127.0.0.1', port, path: '/api/a2ui/answer?questionId=nonexistent', method: 'GET' });
assert.equal(broker.status, 200);
});
after(async () => {
try {
ws?.close();
} catch {}
mcp?.stop();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
process.chdir(originalCwd);
rmSync(projectRoot, { recursive: true, force: true });
mock.restoreAll();
});
it('returns the answered value via MCP tool call', async () => {
const questionId = `e2e-q-${Date.now()}`;
const toolCallPromise = mcp.call(
'tools/call',
{
name: 'ask_question',
arguments: {
question: {
id: questionId,
type: 'confirm',
title: 'E2E Confirm',
message: 'Confirm this in the test harness',
},
timeout: 15000,
},
},
30000,
);
const surfaceMsg = await waitForA2UISurface(ws, 15000);
const surfaceId = surfaceMsg.payload.surfaceId as string;
const receivedQuestionId = surfaceMsg.payload.initialState.questionId as string;
assert.equal(receivedQuestionId, questionId);
ws.send(
JSON.stringify({
type: 'a2ui-action',
actionId: 'confirm',
surfaceId,
parameters: { questionId },
timestamp: new Date().toISOString(),
}),
);
const response = await toolCallPromise;
assert.equal(response.jsonrpc, '2.0');
assert.ok(response.result);
assert.ok(Array.isArray(response.result.content));
const text = response.result.content[0]?.text as string;
const parsed = JSON.parse(text);
const resultObj = parsed.result ?? parsed;
assert.equal(resultObj.success, true);
assert.equal(resultObj.cancelled, false);
assert.ok(Array.isArray(resultObj.answers));
assert.equal(resultObj.answers[0].questionId, questionId);
assert.equal(resultObj.answers[0].value, true);
});
});

View File

@@ -5,7 +5,8 @@
* Tests that bash -c commands use single quotes to avoid jq escaping issues
*/
import { describe, it, expect } from 'vitest';
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
// Import the convertToClaudeCodeFormat function logic
// Since it's in a browser JS file, we'll recreate it here for testing
@@ -58,9 +59,9 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const result = convertToClaudeCodeFormat(hookData);
expect(result.hooks[0].command).toMatch(/^bash -c '/);
expect(result.hooks[0].command).toMatch(/'$/);
expect(result.hooks[0].command).not.toMatch(/^bash -c "/);
assert.match(result.hooks[0].command, /^bash -c '/);
assert.match(result.hooks[0].command, /'$/);
assert.doesNotMatch(result.hooks[0].command, /^bash -c "/);
});
it('should preserve jq command double quotes without excessive escaping', () => {
@@ -73,9 +74,9 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const cmd = result.hooks[0].command;
// The jq pattern should remain readable
expect(cmd).toContain('jq -r ".tool_input.command // empty"');
assert.ok(cmd.includes('jq -r ".tool_input.command // empty"'));
// Should not have excessive escaping like \\\"
expect(cmd).not.toContain('\\\\\\"');
assert.ok(!cmd.includes('\\\\\\"'));
});
it('should correctly escape single quotes in script using \'\\\'\'', () => {
@@ -88,8 +89,8 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const cmd = result.hooks[0].command;
// Single quotes should be escaped as '\''
expect(cmd).toContain("'\\''");
expect(cmd).toBe("bash -c 'echo '\\''hello world'\\'''");
assert.ok(cmd.includes("'\\''"));
assert.equal(cmd, "bash -c 'echo '\\''hello world'\\'''");
});
it('should handle danger-bash-confirm hook template correctly', () => {
@@ -102,11 +103,11 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const cmd = result.hooks[0].command;
// Should use single quotes
expect(cmd).toMatch(/^bash -c '/);
assert.match(cmd, /^bash -c '/);
// jq pattern should be intact
expect(cmd).toContain('jq -r ".tool_input.command // empty"');
assert.ok(cmd.includes('jq -r ".tool_input.command // empty"'));
// JSON output should have escaped double quotes (in shell)
expect(cmd).toContain('{\\"hookSpecificOutput\\"');
assert.ok(cmd.includes('{\\"hookSpecificOutput\\"'));
});
it('should handle non-bash commands with original logic', () => {
@@ -117,7 +118,7 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const result = convertToClaudeCodeFormat(hookData);
expect(result.hooks[0].command).toBe('ccw memory track --type file --action read');
assert.equal(result.hooks[0].command, 'ccw memory track --type file --action read');
});
it('should handle bash commands without -c flag with original logic', () => {
@@ -128,7 +129,7 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const result = convertToClaudeCodeFormat(hookData);
expect(result.hooks[0].command).toBe('bash script.sh --arg value');
assert.equal(result.hooks[0].command, 'bash script.sh --arg value');
});
it('should handle args with spaces in non-bash commands', () => {
@@ -139,7 +140,7 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const result = convertToClaudeCodeFormat(hookData);
expect(result.hooks[0].command).toBe('echo "hello world" "another arg"');
assert.equal(result.hooks[0].command, 'echo "hello world" "another arg"');
});
it('should handle already formatted hook data', () => {
@@ -152,7 +153,7 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const result = convertToClaudeCodeFormat(hookData);
expect(result).toBe(hookData);
assert.equal(result, hookData);
});
it('should handle additional args after bash -c script', () => {
@@ -164,8 +165,8 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const result = convertToClaudeCodeFormat(hookData);
const cmd = result.hooks[0].command;
expect(cmd).toMatch(/^bash -c 'echo \$1'/);
expect(cmd).toContain('"hello world"');
assert.match(cmd, /^bash -c 'echo \$1'/);
assert.ok(cmd.includes('"hello world"'));
});
});
@@ -195,11 +196,11 @@ describe('Hook Quoting Fix (Issue #73)', () => {
const cmd = result.hooks[0].command;
// All bash -c commands should use single quotes
expect(cmd).toMatch(/^bash -c '/);
expect(cmd).toMatch(/'$/);
assert.match(cmd, /^bash -c '/);
assert.match(cmd, /'$/);
// jq patterns should be intact
expect(cmd).toContain('jq -r ".');
assert.ok(cmd.includes('jq -r ".'));
});
}
});

View File

@@ -206,9 +206,8 @@ describe('Smart Search Tool Definition', async () => {
const modeEnum = params.properties.mode?.enum;
assert.ok(modeEnum, 'Should have mode enum');
assert.ok(modeEnum.includes('auto'), 'Should support auto mode');
assert.ok(modeEnum.includes('hybrid'), 'Should support hybrid mode');
assert.ok(modeEnum.includes('exact'), 'Should support exact mode');
assert.ok(modeEnum.includes('fuzzy'), 'Should support fuzzy mode');
assert.ok(modeEnum.includes('semantic'), 'Should support semantic mode');
});
});