// ======================================== // 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, isHttpMcpServer, isStdioMcpServer } 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; /** Display text - command for STDIO, URL for HTTP */ displayText: string; enabled: boolean; selected: boolean; } // ========== 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>(new Set()); // Codex servers state const [codexServers, setCodexServers] = useState([]); const [selectedCodex, setSelectedCodex] = useState>(new Set()); const [isLoadingCodex, setIsLoadingCodex] = useState(false); const [codexError, setCodexError] = useState(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, displayText: isHttpMcpServer(s) ? s.url : (isStdioMcpServer(s) ? 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 (
{/* Header */}

{formatMessage({ id: 'mcp.sync.title' })}

{formatMessage({ id: 'mcp.sync.description' })}

{/* Result Message */} {copyResult.type !== null && (
{copyResult.type === 'success' ? ( ) : ( )} {copyResult.type === 'success' ? formatMessage( { id: 'mcp.sync.copySuccess' }, { count: copyResult.copied } ) : formatMessage( { id: 'mcp.sync.copyPartial' }, { copied: copyResult.copied, failed: copyResult.failed } )}
)} {/* Two-column layout */}
{/* Claude Column */}
{/* Column Header */}

{formatMessage({ id: 'mcp.sync.claudeColumn' })}

{formatMessage( { id: 'mcp.sync.selectedCount' }, { count: claudeSelected, total: claudeTotal } )}
{/* Claude Server List */}
{claudeTotal === 0 ? (
{formatMessage({ id: 'mcp.sync.noServers' })}
) : (
{claudeServers.map((server) => (
toggleClaudeServer(server.name)} > toggleClaudeServer(server.name)} className="w-4 h-4 mt-0.5" />
))}
)}
{/* Claude Footer Actions */} {claudeTotal > 0 && (
)}
{/* Codex Column */}
{/* Column Header */}

{formatMessage({ id: 'mcp.sync.codexColumn' })}

{formatMessage( { id: 'mcp.sync.selectedCount' }, { count: codexSelected, total: codexTotal } )}
{/* Codex Server List */}
{isLoadingCodex ? (
) : codexError ? (
{codexError}
) : codexTotal === 0 ? (
{formatMessage({ id: 'mcp.sync.noServers' })}
) : (
{codexServers.map((server) => (
toggleCodexServer(server.name)} > toggleCodexServer(server.name)} className="w-4 h-4 mt-0.5" />
))}
)}
{/* Codex Footer Actions */} {codexTotal > 0 && (
)}
{/* Copy Buttons */}
); } export default CrossCliSyncPanel;