Files
Claude-Code-Workflow/ccw/src/tools/edit-file.ts
catlog22 45f92fe066 feat: 实现 MCP 工具集中式路径验证,增强安全性和可配置性
- 新增 path-validator.ts:参考 MCP filesystem 服务器设计的集中式路径验证器
  - 支持 CCW_PROJECT_ROOT 和 CCW_ALLOWED_DIRS 环境变量配置
  - 多层路径验证:绝对路径解析 → 沙箱检查 → 符号链接验证
  - 向后兼容:未设置环境变量时回退到 process.cwd()

- 更新所有 MCP 工具使用集中式路径验证:
  - write-file.ts: 使用 validatePath()
  - edit-file.ts: 使用 validatePath({ mustExist: true })
  - read-file.ts: 使用 validatePath() + getProjectRoot()
  - smart-search.ts: 使用 getProjectRoot()
  - core-memory.ts: 使用 getProjectRoot()

- MCP 服务器启动时输出项目根目录和允许目录信息

- MCP 管理界面增强:
  - CCW Tools 安装卡片新增路径设置 UI
  - 支持 CCW_PROJECT_ROOT 和 CCW_ALLOWED_DIRS 配置
  - 添加"使用当前项目"快捷按钮
  - 支持 Claude 和 Codex 两种模式
  - 添加中英文国际化翻译

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 18:14:06 +08:00

569 lines
17 KiB
TypeScript

/**
* Edit File Tool - AI-focused file editing
* Two complementary modes:
* - update: Content-driven text replacement (AI primary use)
* - line: Position-driven line operations (precise control)
*
* Features:
* - dryRun mode for previewing changes
* - Git-style diff output
* - Multi-edit support in update mode
* - Auto line-ending adaptation (CRLF/LF)
*/
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { resolve, isAbsolute, dirname } from 'path';
import { validatePath } from '../utils/path-validator.js';
// Define Zod schemas for validation
const EditItemSchema = z.object({
oldText: z.string(),
newText: z.string(),
});
const ParamsSchema = 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
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(),
text: z.string().optional(),
});
type Params = z.infer<typeof ParamsSchema>;
type EditItem = z.infer<typeof EditItemSchema>;
interface UpdateModeResult {
content: string;
modified: boolean;
status: string;
replacements: number;
editResults: Array<Record<string, unknown>>;
diff: string;
dryRun: boolean;
message: string;
}
// Max lines to show in diff preview
const MAX_DIFF_LINES = 15;
interface LineModeResult {
content: string;
modified: boolean;
operation: string;
line: number;
end_line?: number;
message: string;
}
// Internal type for mode results (content excluded in final output)
/**
* Resolve file path and read content
* @param filePath - Path to file
* @returns Resolved path and content
*/
async function readFile(filePath: string): Promise<{ resolvedPath: string; content: string }> {
const resolvedPath = await validatePath(filePath, { mustExist: true });
if (!existsSync(resolvedPath)) {
throw new Error(`File not found: ${resolvedPath}`);
}
try {
const content = readFileSync(resolvedPath, 'utf8');
return { resolvedPath, content };
} catch (error) {
throw new Error(`Failed to read file: ${(error as Error).message}`);
}
}
/**
* Write content to file with optional parent directory creation
* @param filePath - Path to file
* @param content - Content to write
* @param createDirs - Create parent directories if needed
*/
function writeFile(filePath: string, content: string, createDirs = false): void {
try {
if (createDirs) {
const dir = dirname(filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
writeFileSync(filePath, content, 'utf8');
} catch (error) {
throw new Error(`Failed to write file: ${(error as Error).message}`);
}
}
/**
* Normalize line endings to LF
* @param text - Input text
* @returns Text with LF line endings
*/
function normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n');
}
/**
* Create unified diff between two strings
* @param original - Original content
* @param modified - Modified content
* @param filePath - File path for diff header
* @returns Unified diff string
*/
function createUnifiedDiff(original: string, modified: string, filePath: string): string {
const origLines = normalizeLineEndings(original).split('\n');
const modLines = normalizeLineEndings(modified).split('\n');
const diffLines = [`--- a/${filePath}`, `+++ b/${filePath}`];
// Simple diff algorithm - find changes
let i = 0,
j = 0;
let hunk: string[] = [];
let origStart = 0;
let modStart = 0;
while (i < origLines.length || j < modLines.length) {
if (i < origLines.length && j < modLines.length && origLines[i] === modLines[j]) {
// Context line
if (hunk.length > 0) {
hunk.push(` ${origLines[i]}`);
}
i++;
j++;
} else {
// Start or continue hunk
if (hunk.length === 0) {
origStart = i + 1;
modStart = j + 1;
// Add context before
const contextStart = Math.max(0, i - 3);
for (let c = contextStart; c < i; c++) {
hunk.push(` ${origLines[c]}`);
}
origStart = contextStart + 1;
modStart = contextStart + 1;
}
// Find where lines match again
let foundMatch = false;
for (let lookAhead = 1; lookAhead <= 10; lookAhead++) {
if (
i + lookAhead < origLines.length &&
j < modLines.length &&
origLines[i + lookAhead] === modLines[j]
) {
// Remove lines from original
for (let r = 0; r < lookAhead; r++) {
hunk.push(`-${origLines[i + r]}`);
}
i += lookAhead;
foundMatch = true;
break;
}
if (
j + lookAhead < modLines.length &&
i < origLines.length &&
modLines[j + lookAhead] === origLines[i]
) {
// Add lines to modified
for (let a = 0; a < lookAhead; a++) {
hunk.push(`+${modLines[j + a]}`);
}
j += lookAhead;
foundMatch = true;
break;
}
}
if (!foundMatch) {
// Replace line
if (i < origLines.length) {
hunk.push(`-${origLines[i]}`);
i++;
}
if (j < modLines.length) {
hunk.push(`+${modLines[j]}`);
j++;
}
}
}
// Flush hunk if we've had 3 context lines after changes
const lastChangeIdx = hunk.findLastIndex((l) => l.startsWith('+') || l.startsWith('-'));
if (lastChangeIdx >= 0 && hunk.length - lastChangeIdx > 3) {
const origCount = hunk.filter((l) => !l.startsWith('+')).length;
const modCount = hunk.filter((l) => !l.startsWith('-')).length;
diffLines.push(`@@ -${origStart},${origCount} +${modStart},${modCount} @@`);
diffLines.push(...hunk);
hunk = [];
}
}
// Flush remaining hunk
if (hunk.length > 0) {
const origCount = hunk.filter((l) => !l.startsWith('+')).length;
const modCount = hunk.filter((l) => !l.startsWith('-')).length;
diffLines.push(`@@ -${origStart},${origCount} +${modStart},${modCount} @@`);
diffLines.push(...hunk);
}
return diffLines.length > 2 ? diffLines.join('\n') : '';
}
/**
* Mode: update - Simple text replacement
* Auto-adapts line endings (CRLF/LF)
* Supports multiple edits via 'edits' array
*/
function executeUpdateMode(content: string, params: Params, filePath: string): UpdateModeResult {
const { oldText, newText, replaceAll, edits, dryRun = false } = params;
// Detect original line ending
const hasCRLF = content.includes('\r\n');
const normalizedContent = normalizeLineEndings(content);
const originalContent = normalizedContent;
let newContent = normalizedContent;
let replacements = 0;
const editResults: Array<Record<string, unknown>> = [];
// Support multiple edits via 'edits' array
const editOperations: EditItem[] =
edits || (oldText !== undefined ? [{ oldText, newText: newText || '' }] : []);
if (editOperations.length === 0) {
throw new Error('Either "oldText/newText" or "edits" array is required for update mode');
}
for (const edit of editOperations) {
const normalizedOld = normalizeLineEndings(edit.oldText || '');
const normalizedNew = normalizeLineEndings(edit.newText || '');
if (!normalizedOld) {
editResults.push({ status: 'error', message: 'Empty oldText' });
continue;
}
if (newContent.includes(normalizedOld)) {
if (replaceAll) {
const parts = newContent.split(normalizedOld);
const count = parts.length - 1;
newContent = parts.join(normalizedNew);
replacements += count;
editResults.push({ status: 'replaced_all', count });
} else {
newContent = newContent.replace(normalizedOld, normalizedNew);
replacements += 1;
editResults.push({ status: 'replaced', count: 1 });
}
} else {
// Try fuzzy match (trimmed whitespace)
const lines = newContent.split('\n');
const oldLines = normalizedOld.split('\n');
let matchFound = false;
for (let i = 0; i <= lines.length - oldLines.length; i++) {
const potentialMatch = lines.slice(i, i + oldLines.length);
const isMatch = oldLines.every(
(oldLine, j) => oldLine.trim() === potentialMatch[j].trim()
);
if (isMatch) {
// Preserve indentation of first line
const indent = lines[i].match(/^\s*/)?.[0] || '';
const newLines = normalizedNew.split('\n').map((line, j) => {
if (j === 0) return indent + line.trimStart();
return line;
});
lines.splice(i, oldLines.length, ...newLines);
newContent = lines.join('\n');
replacements += 1;
editResults.push({ status: 'replaced_fuzzy', count: 1 });
matchFound = true;
break;
}
}
if (!matchFound) {
editResults.push({ status: 'not_found', oldText: normalizedOld.substring(0, 50) });
}
}
}
// Restore original line ending
if (hasCRLF) {
newContent = newContent.replace(/\n/g, '\r\n');
}
// Generate diff if content changed
let diff = '';
if (originalContent !== normalizeLineEndings(newContent)) {
diff = createUnifiedDiff(originalContent, normalizeLineEndings(newContent), filePath);
}
return {
content: newContent,
modified: content !== newContent,
status: replacements > 0 ? 'replaced' : 'not found',
replacements,
editResults,
diff,
dryRun,
message:
replacements > 0
? `${replacements} replacement(s) made${dryRun ? ' (dry run)' : ''}`
: 'No matches found',
};
}
/**
* Mode: line - Line-based operations
* Operations: insert_before, insert_after, replace, delete
*/
function executeLineMode(content: string, params: Params): 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');
// Detect original line ending and normalize for processing
const hasCRLF = content.includes('\r\n');
const normalizedContent = hasCRLF ? content.replace(/\r\n/g, '\n') : content;
const lines = normalizedContent.split('\n');
const lineIndex = line - 1; // Convert to 0-based
if (lineIndex < 0 || lineIndex >= lines.length) {
throw new Error(`Line ${line} out of range (1-${lines.length})`);
}
const newLines = [...lines];
let message = '';
switch (operation) {
case 'insert_before':
if (text === undefined) throw new Error('Parameter "text" is required for insert_before');
newLines.splice(lineIndex, 0, text);
message = `Inserted before line ${line}`;
break;
case 'insert_after':
if (text === undefined) throw new Error('Parameter "text" is required for insert_after');
newLines.splice(lineIndex + 1, 0, text);
message = `Inserted after line ${line}`;
break;
case 'replace': {
if (text === undefined) throw new Error('Parameter "text" is required for replace');
const endIdx = end_line ? end_line - 1 : lineIndex;
if (endIdx < lineIndex || endIdx >= lines.length) {
throw new Error(`end_line ${end_line} is invalid`);
}
const deleteCount = endIdx - lineIndex + 1;
newLines.splice(lineIndex, deleteCount, text);
message = end_line ? `Replaced lines ${line}-${end_line}` : `Replaced line ${line}`;
break;
}
case 'delete': {
const endDelete = end_line ? end_line - 1 : lineIndex;
if (endDelete < lineIndex || endDelete >= lines.length) {
throw new Error(`end_line ${end_line} is invalid`);
}
const count = endDelete - lineIndex + 1;
newLines.splice(lineIndex, count);
message = end_line ? `Deleted lines ${line}-${end_line}` : `Deleted line ${line}`;
break;
}
default:
throw new Error(
`Unknown operation: ${operation}. Valid: insert_before, insert_after, replace, delete`
);
}
let newContent = newLines.join('\n');
// Restore original line endings
if (hasCRLF) {
newContent = newContent.replace(/\n/g, '\r\n');
}
return {
content: newContent,
modified: content !== newContent,
operation,
line,
end_line,
message,
};
}
// Tool schema for MCP
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):
edit_file(path="f.js", oldText="old", newText="new")
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)`,
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Path to the file to modify',
},
mode: {
type: 'string',
enum: ['update', 'line'],
description: 'Edit mode (default: update)',
default: 'update',
},
dryRun: {
type: 'boolean',
description: 'Preview changes using git-style diff without modifying file (default: false)',
default: false,
},
// Update mode params
oldText: {
type: 'string',
description: '[update mode] Text to find and replace (use oldText/newText OR edits array)',
},
newText: {
type: 'string',
description: '[update mode] Replacement text',
},
edits: {
type: 'array',
description: '[update mode] Array of {oldText, newText} for multiple replacements',
items: {
type: 'object',
properties: {
oldText: { type: 'string', description: 'Text to search for - must match exactly' },
newText: { type: 'string', description: 'Text to replace with' },
},
required: ['oldText', 'newText'],
},
},
replaceAll: {
type: 'boolean',
description: '[update mode] Replace all occurrences of oldText (default: false)',
},
// Line mode params
operation: {
type: 'string',
enum: ['insert_before', 'insert_after', 'replace', 'delete'],
description: '[line mode] Line operation type',
},
line: {
type: 'number',
description: '[line mode] Line number (1-based)',
},
end_line: {
type: 'number',
description: '[line mode] End line for range operations',
},
text: {
type: 'string',
description: '[line mode] Text for insert/replace operations',
},
},
required: ['path'],
},
};
/**
* Truncate diff to max lines with indicator
*/
function truncateDiff(diff: string, maxLines: number): string {
if (!diff) return '';
const lines = diff.split('\n');
if (lines.length <= maxLines) return diff;
return lines.slice(0, maxLines).join('\n') + `\n... (+${lines.length - maxLines} more lines)`;
}
/**
* Build compact result for output
*/
interface CompactEditResult {
path: string;
modified: boolean;
message: string;
replacements?: number;
diff?: string;
dryRun?: boolean;
}
// Handler function
export async function handler(params: Record<string, unknown>): Promise<ToolResult<CompactEditResult>> {
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return { success: false, error: `Invalid params: ${parsed.error.message}` };
}
const { path: filePath, mode = 'update', dryRun = false } = 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`);
}
// Write if modified and not dry run
if (result.modified && !dryRun) {
writeFile(resolvedPath, result.content);
}
// Build compact result
const compactResult: CompactEditResult = {
path: resolvedPath,
modified: result.modified,
message: result.message,
};
// Add mode-specific fields
if ('replacements' in result) {
compactResult.replacements = result.replacements;
compactResult.dryRun = result.dryRun;
// Truncate diff for compact output
if (result.diff) {
compactResult.diff = truncateDiff(result.diff, MAX_DIFF_LINES);
}
}
return { success: true, result: compactResult };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}