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

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,
},
},