mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
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:
1
ccw/src/.gitignore
vendored
Normal file
1
ccw/src/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.ace-tool/
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ const LOCALHOST_PUBLIC_PATHS = [
|
||||
'/api/litellm-api/providers',
|
||||
'/api/litellm-api/endpoints',
|
||||
'/api/health',
|
||||
'/api/a2ui/answer',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user