fileditor-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/docs/MCP-INTERFACE.cn.md +976 -0
- package/docs/MCP-INTERFACE.en.md +881 -0
- package/docs/README.cn.md +128 -0
- package/package.json +27 -0
- package/src/handlers/applyDiff.js +298 -0
- package/src/handlers/insertContent.js +179 -0
- package/src/handlers/listFiles.js +65 -0
- package/src/handlers/readFile.js +109 -0
- package/src/handlers/searchAndReplace.js +198 -0
- package/src/handlers/setWorkspace.js +26 -0
- package/src/handlers/writeFile.js +98 -0
- package/src/index.js +20 -0
- package/src/server.js +85 -0
- package/src/tools/toolDefinitions.js +291 -0
- package/src/utils/fileUtils.js +238 -0
- package/test/ApplyDiffHandler.test.js +754 -0
- package/test/InsertContentHandler.test.js +371 -0
- package/test/ListFilesHandler.test.js +302 -0
- package/test/ReadFileHandler.test.js +213 -0
- package/test/SearchAndReplaceHandler.test.js +505 -0
- package/test/SetWorkspaceHandler.test.js +290 -0
- package/test/WriteFileHandler.test.js +289 -0
- package/test/runAllTests.js +233 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# FileEditor MCP Server
|
|
2
|
+
|
|
3
|
+
[📖 English Interface Doc](./MCP-INTERFACE.en.md) | [📖 中文接口文档](./MCP-INTERFACE.cn.md) | [📋 English README](../README.md)
|
|
4
|
+
|
|
5
|
+
**专为 AI 编程助手优化的文件操作服务器** - 基于 Model Context Protocol 构建,针对代码编辑、重构、批量修改等AI编程场景深度优化,提供高精度、高效率的文件系统操作能力。
|
|
6
|
+
|
|
7
|
+
## 🎯 设计理念
|
|
8
|
+
|
|
9
|
+
**为 AI 编程而生** - 本项目专门针对 AI 模型的代码编辑需求进行设计,提供精确的块级操作、智能匹配算法和批量处理能力,让 AI 能够安全、高效地执行复杂的代码修改任务。
|
|
10
|
+
|
|
11
|
+
## 🚀 核心特性
|
|
12
|
+
|
|
13
|
+
### 📁 专业文件操作工具 (7个)
|
|
14
|
+
- **`set_workspace`** - 安全工作区管理 (强制隔离,防止误操作)
|
|
15
|
+
- **`read_files`** - 智能批量读取 (支持多文件、行范围、带行号定位)
|
|
16
|
+
- **`write_files`** - 高效文件创建 (批量写入、原子性保证)
|
|
17
|
+
- **`list_files`** - 完整目录遍历 (递归扫描、结构化输出)
|
|
18
|
+
- **`insert_contents`** - 精确内容插入 (多点插入、负索引、末尾追加)
|
|
19
|
+
- **`apply_diffs`** - **AI友好的差异应用** (智能空格处理、批量原子操作、容错匹配)
|
|
20
|
+
- **`search_and_replace`** - 强大模式替换 (正则支持、范围限定、大小写控制)
|
|
21
|
+
|
|
22
|
+
### 🛡️ 企业级安全保障
|
|
23
|
+
- **工作区强隔离**: 严格边界控制,防止目录穿透和文件泄露
|
|
24
|
+
- **路径智能解析**: 自动验证和标准化文件路径
|
|
25
|
+
- **原子事务**: 批量操作要么全部成功,要么完全回滚
|
|
26
|
+
|
|
27
|
+
### ⚡ AI 优化功能
|
|
28
|
+
- **批量处理引擎**: 单次 API 调用处理大量文件操作
|
|
29
|
+
- **智能匹配算法**: `trim` 模式处理代码格式差异,容错性强
|
|
30
|
+
- **详细操作反馈**: 完整的成功/失败信息,便于 AI 调试和决策
|
|
31
|
+
- **非阻塞错误处理**: 部分失败不影响其他操作继续执行
|
|
32
|
+
|
|
33
|
+
## 🛠️ 快速开始
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# 安装依赖 (推荐使用 pnpm)
|
|
37
|
+
pnpm install
|
|
38
|
+
|
|
39
|
+
# 启动生产服务器
|
|
40
|
+
pnpm start
|
|
41
|
+
|
|
42
|
+
# 开发模式 (文件变更自动重启)
|
|
43
|
+
pnpm dev
|
|
44
|
+
|
|
45
|
+
# 运行完整测试套件
|
|
46
|
+
pnpm test
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**环境要求**: Node.js ≥18, pnpm 包管理器
|
|
50
|
+
|
|
51
|
+
## 💡 典型使用场景
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
// 场景1: 工作区初始化 (必须首先执行)
|
|
55
|
+
{
|
|
56
|
+
"name": "set_workspace",
|
|
57
|
+
"arguments": { "path": "/path/to/your/project" }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 场景2: 批量代码文件分析
|
|
61
|
+
{
|
|
62
|
+
"name": "read_files",
|
|
63
|
+
"arguments": {
|
|
64
|
+
"path": ["src/main.js", "src/utils.js", "package.json"],
|
|
65
|
+
"line_range": "1-50" // 可选:仅读取前50行
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 场景3: 智能代码重构 (容错空格差异)
|
|
70
|
+
{
|
|
71
|
+
"name": "apply_diffs",
|
|
72
|
+
"arguments": {
|
|
73
|
+
"path": "src/config.js",
|
|
74
|
+
"search_content": [
|
|
75
|
+
"const API_URL = 'localhost';",
|
|
76
|
+
"const PORT = 3000;"
|
|
77
|
+
],
|
|
78
|
+
"replace_content": [
|
|
79
|
+
"const API_URL = process.env.API_URL || 'localhost';",
|
|
80
|
+
"const PORT = process.env.PORT || 3000;"
|
|
81
|
+
],
|
|
82
|
+
"start_line": [5, 7],
|
|
83
|
+
"atomic": true, // 原子模式:全部成功或全部回滚
|
|
84
|
+
"trim": true // 智能模式:忽略空格差异
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 🏗️ 架构设计
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
src/
|
|
93
|
+
├── index.js # 服务启动入口
|
|
94
|
+
├── server.js # MCP 协议服务器
|
|
95
|
+
├── tools/
|
|
96
|
+
│ └── toolDefinitions.js # 工具定义和 JSON Schema
|
|
97
|
+
├── handlers/ # 核心处理器 (7个)
|
|
98
|
+
│ ├── applyDiff.js # 差异应用 (支持批量+原子+智能匹配)
|
|
99
|
+
│ ├── readFile.js # 文件读取器
|
|
100
|
+
│ ├── writeFile.js # 文件写入器
|
|
101
|
+
│ ├── listFiles.js # 目录扫描器
|
|
102
|
+
│ ├── insertContent.js # 内容插入器
|
|
103
|
+
│ ├── searchAndReplace.js # 模式替换器
|
|
104
|
+
│ └── setWorkspace.js # 工作区管理器
|
|
105
|
+
└── utils/
|
|
106
|
+
└── fileUtils.js # 通用文件操作库
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**架构优势**: 高度模块化、职责明确、易于维护、便于 AI 理解和调用
|
|
110
|
+
|
|
111
|
+
## 📊 质量保证
|
|
112
|
+
|
|
113
|
+
- ✅ **100% 测试覆盖率** - 7个完整的处理器测试套件
|
|
114
|
+
- ✅ **边界条件验证** - 异常输入和错误状态处理
|
|
115
|
+
- ✅ **批量操作验证** - 原子性和一致性保证
|
|
116
|
+
- ✅ **安全性测试** - 路径注入和权限验证
|
|
117
|
+
|
|
118
|
+
## 🎉 为什么选择 FileEditor MCP
|
|
119
|
+
|
|
120
|
+
1. **AI 原生设计** - 专门为 AI 编程助手的工作模式优化
|
|
121
|
+
2. **高性能批处理** - 减少 API 调用次数,提升处理效率
|
|
122
|
+
3. **智能容错** - 处理现实代码中的格式和空格差异
|
|
123
|
+
4. **企业级安全** - 严格的工作区隔离和权限控制
|
|
124
|
+
5. **完整测试覆盖** - 可靠性和稳定性保证
|
|
125
|
+
|
|
126
|
+
## 📄 开源协议
|
|
127
|
+
|
|
128
|
+
MIT License - 欢迎贡献和使用
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fileditor-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for file operations",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node src/index.js",
|
|
9
|
+
"dev": "node --watch src/index.js",
|
|
10
|
+
"test": "node test/runAllTests.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"mcp",
|
|
14
|
+
"file-operations",
|
|
15
|
+
"server"
|
|
16
|
+
],
|
|
17
|
+
"author": "lansya",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^0.5.0",
|
|
21
|
+
"path-is-inside": "^1.0.2"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
|
27
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { FileUtils } from '../utils/fileUtils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* apply_diffs tool handler
|
|
5
|
+
*/
|
|
6
|
+
export class ApplyDiffHandler {
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Format content with line numbers for error display
|
|
10
|
+
* @param {string} content - Content to format
|
|
11
|
+
* @param {number} startLine - Starting line number
|
|
12
|
+
* @returns {string} Formatted content with line numbers
|
|
13
|
+
*/
|
|
14
|
+
static formatContentWithLineNumbers(content, startLine) {
|
|
15
|
+
const lines = content.split('\n');
|
|
16
|
+
return lines.map((line, index) => `${startLine + index} | ${line}`).join('\n');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate a single diff operation
|
|
21
|
+
* @param {Object} diff - Diff operation to validate
|
|
22
|
+
* @param {Array} lines - File lines
|
|
23
|
+
* @param {number} lineOffset - Current line offset
|
|
24
|
+
* @param {boolean} trim - Whether to trim whitespace when comparing
|
|
25
|
+
* @returns {Object} Validation result
|
|
26
|
+
*/
|
|
27
|
+
static validateDiff(diff, lines, lineOffset, trim = false) {
|
|
28
|
+
const { search_content: searchContent, start_line: originalStartLine, originalIndex } = diff;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Calculate actual start line with offset
|
|
32
|
+
const actualStartLine = originalStartLine + lineOffset;
|
|
33
|
+
|
|
34
|
+
// Check if start line number exceeds current file range
|
|
35
|
+
if (actualStartLine > lines.length) {
|
|
36
|
+
throw new Error(`start_line (${originalStartLine} -> ${actualStartLine}) exceeds file length (${lines.length} lines)`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Split search content into line arrays
|
|
40
|
+
const searchLines = searchContent.split('\n');
|
|
41
|
+
|
|
42
|
+
// Determine the end line number for search
|
|
43
|
+
const endLine = actualStartLine + searchLines.length - 1;
|
|
44
|
+
|
|
45
|
+
// Check if search range exceeds file range
|
|
46
|
+
if (endLine > lines.length) {
|
|
47
|
+
throw new Error(`Search content extends beyond file length. Start line: ${originalStartLine} (actual: ${actualStartLine}), search lines: ${searchLines.length}, file lines: ${lines.length}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Extract content to match
|
|
51
|
+
const targetLines = lines.slice(actualStartLine - 1, endLine);
|
|
52
|
+
const targetContent = targetLines.join('\n');
|
|
53
|
+
|
|
54
|
+
// Prepare content for comparison based on trim setting
|
|
55
|
+
let searchForComparison = searchContent;
|
|
56
|
+
let targetForComparison = targetContent;
|
|
57
|
+
|
|
58
|
+
if (trim) {
|
|
59
|
+
// Apply trim to each line and rejoin
|
|
60
|
+
const searchLinesForComparison = searchContent.split('\n').map(line => line.trim());
|
|
61
|
+
const targetLinesForComparison = targetLines.map(line => line.trim());
|
|
62
|
+
|
|
63
|
+
searchForComparison = searchLinesForComparison.join('\n');
|
|
64
|
+
targetForComparison = targetLinesForComparison.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Exact match check (using trimmed or original content based on trim setting)
|
|
68
|
+
if (targetForComparison !== searchForComparison) {
|
|
69
|
+
// Always show original content in error messages for debugging
|
|
70
|
+
const expectedFormatted = this.formatContentWithLineNumbers(searchContent, actualStartLine);
|
|
71
|
+
const actualFormatted = this.formatContentWithLineNumbers(targetContent, actualStartLine);
|
|
72
|
+
const detailedError = `Content mismatch at line ${originalStartLine} (actual: ${actualStartLine}).\n\nExpected content:\n${expectedFormatted}\n\nActual content:\n${actualFormatted}\n\nThis is diff #${originalIndex + 1} in the batch.`;
|
|
73
|
+
throw new Error(detailedError);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
actualStartLine,
|
|
79
|
+
endLine,
|
|
80
|
+
searchLines
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
error: error.message,
|
|
86
|
+
originalIndex,
|
|
87
|
+
originalStartLine
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Handle apply_diffs request (supports both single diff and batch diffs)
|
|
96
|
+
* @param {Object} args - Request parameters
|
|
97
|
+
* @returns {Promise<Object>} Response result
|
|
98
|
+
*/
|
|
99
|
+
static async handle(args) {
|
|
100
|
+
const { path, search_content, replace_content, start_line, atomic = true, trim = false } = args;
|
|
101
|
+
|
|
102
|
+
// Check if file exists
|
|
103
|
+
if (!FileUtils.fileExists(path)) {
|
|
104
|
+
throw new Error(`File not found: ${path}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Normalize all parameters to arrays
|
|
108
|
+
let searchArray, replaceArray, startLineArray;
|
|
109
|
+
|
|
110
|
+
if (Array.isArray(search_content)) {
|
|
111
|
+
searchArray = search_content;
|
|
112
|
+
replaceArray = Array.isArray(replace_content) ? replace_content : [replace_content];
|
|
113
|
+
startLineArray = Array.isArray(start_line) ? start_line : [start_line];
|
|
114
|
+
} else {
|
|
115
|
+
searchArray = [search_content];
|
|
116
|
+
replaceArray = Array.isArray(replace_content) ? replace_content : [replace_content];
|
|
117
|
+
startLineArray = Array.isArray(start_line) ? start_line : [start_line];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Validate array lengths match
|
|
121
|
+
if (searchArray.length !== replaceArray.length || searchArray.length !== startLineArray.length) {
|
|
122
|
+
throw new Error("Array lengths for search_content, replace_content, and start_line must match");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const diffCount = searchArray.length;
|
|
126
|
+
|
|
127
|
+
// Validate all start_line values
|
|
128
|
+
for (let i = 0; i < diffCount; i++) {
|
|
129
|
+
if (startLineArray[i] < 1) {
|
|
130
|
+
throw new Error(`Invalid start_line: ${startLineArray[i]} at index ${i}. Line numbers start from 1.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Create diff objects and sort by start_line
|
|
135
|
+
const diffs = searchArray.map((searchContent, index) => ({
|
|
136
|
+
search_content: searchContent,
|
|
137
|
+
replace_content: replaceArray[index],
|
|
138
|
+
start_line: startLineArray[index],
|
|
139
|
+
originalIndex: index
|
|
140
|
+
})).sort((a, b) => a.start_line - b.start_line);
|
|
141
|
+
|
|
142
|
+
// Read file content
|
|
143
|
+
const content = await FileUtils.readFile(path);
|
|
144
|
+
let lines = content.split('\n');
|
|
145
|
+
if (atomic && diffCount > 1) {
|
|
146
|
+
// Atomic mode: validate all diffs first
|
|
147
|
+
let lineOffset = 0;
|
|
148
|
+
const validationErrors = [];
|
|
149
|
+
let tempLines = [...lines]; // Work with a copy for validation
|
|
150
|
+
|
|
151
|
+
for (const diff of diffs) {
|
|
152
|
+
const validation = this.validateDiff(diff, tempLines, lineOffset, trim);
|
|
153
|
+
|
|
154
|
+
if (!validation.success) {
|
|
155
|
+
validationErrors.push({
|
|
156
|
+
index: validation.originalIndex,
|
|
157
|
+
start_line: validation.originalStartLine,
|
|
158
|
+
status: "fail",
|
|
159
|
+
message: validation.error
|
|
160
|
+
});
|
|
161
|
+
break; // Stop validation on first error
|
|
162
|
+
} else {
|
|
163
|
+
// Simulate the replacement to update tempLines and lineOffset
|
|
164
|
+
const replaceLines = diff.replace_content.split('\n');
|
|
165
|
+
const actualStartLine = diff.start_line + lineOffset;
|
|
166
|
+
const endLine = actualStartLine + validation.searchLines.length - 1;
|
|
167
|
+
|
|
168
|
+
const beforeLines = tempLines.slice(0, actualStartLine - 1);
|
|
169
|
+
const afterLines = tempLines.slice(endLine);
|
|
170
|
+
tempLines = [...beforeLines, ...replaceLines, ...afterLines];
|
|
171
|
+
|
|
172
|
+
// Update line offset for next validation
|
|
173
|
+
const lineDiff = replaceLines.length - validation.searchLines.length;
|
|
174
|
+
lineOffset += lineDiff;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (validationErrors.length > 0) {
|
|
179
|
+
// In atomic mode, if any diff fails, abort the entire operation
|
|
180
|
+
const results = new Array(diffCount);
|
|
181
|
+
validationErrors.forEach(error => {
|
|
182
|
+
results[error.index] = error;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Fill in success placeholders for non-failed diffs
|
|
186
|
+
for (let i = 0; i < diffCount; i++) {
|
|
187
|
+
if (!results[i]) {
|
|
188
|
+
results[i] = {
|
|
189
|
+
index: i,
|
|
190
|
+
start_line: startLineArray[i],
|
|
191
|
+
status: "aborted",
|
|
192
|
+
message: "Operation aborted due to validation failures in atomic mode"
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Create detailed error message with formatted content
|
|
198
|
+
let detailedMessage = `Atomic operation failed: ${validationErrors.length}/${diffCount} diffs would fail. No changes applied.\n\n`;
|
|
199
|
+
detailedMessage += "Detailed results:\n";
|
|
200
|
+
results.forEach((result, index) => {
|
|
201
|
+
detailedMessage += `\nDiff ${index + 1}:\n`;
|
|
202
|
+
detailedMessage += ` Status: ${result.status}\n`;
|
|
203
|
+
detailedMessage += ` Start Line: ${result.start_line}\n`;
|
|
204
|
+
detailedMessage += ` Message: ${result.message}\n`;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
throw new Error(detailedMessage);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Track results for each diff
|
|
212
|
+
const results = new Array(diffCount);
|
|
213
|
+
let lineOffset = 0; // Track cumulative line offset
|
|
214
|
+
let appliedCount = 0;
|
|
215
|
+
|
|
216
|
+
// Process each diff in order
|
|
217
|
+
for (const diff of diffs) {
|
|
218
|
+
const { search_content: searchContent, replace_content: replaceContent, start_line: originalStartLine, originalIndex } = diff;
|
|
219
|
+
|
|
220
|
+
if (!atomic || diffCount === 1) {
|
|
221
|
+
// Non-atomic mode or single diff: validate and apply one by one
|
|
222
|
+
const validation = this.validateDiff(diff, lines, lineOffset, trim);
|
|
223
|
+
|
|
224
|
+
if (!validation.success) {
|
|
225
|
+
results[originalIndex] = {
|
|
226
|
+
index: originalIndex,
|
|
227
|
+
start_line: originalStartLine,
|
|
228
|
+
status: "fail",
|
|
229
|
+
message: validation.error
|
|
230
|
+
};
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Apply the diff
|
|
236
|
+
const actualStartLine = originalStartLine + lineOffset;
|
|
237
|
+
const searchLines = searchContent.split('\n');
|
|
238
|
+
const replaceLines = replaceContent.split('\n');
|
|
239
|
+
const endLine = actualStartLine + searchLines.length - 1;
|
|
240
|
+
|
|
241
|
+
// Perform replacement
|
|
242
|
+
const beforeLines = lines.slice(0, actualStartLine - 1);
|
|
243
|
+
const afterLines = lines.slice(endLine);
|
|
244
|
+
lines = [...beforeLines, ...replaceLines, ...afterLines];
|
|
245
|
+
|
|
246
|
+
// Update line offset for subsequent diffs
|
|
247
|
+
const lineDiff = replaceLines.length - searchLines.length;
|
|
248
|
+
lineOffset += lineDiff;
|
|
249
|
+
appliedCount++;
|
|
250
|
+
|
|
251
|
+
// Record success
|
|
252
|
+
results[originalIndex] = {
|
|
253
|
+
index: originalIndex,
|
|
254
|
+
start_line: originalStartLine,
|
|
255
|
+
status: "success",
|
|
256
|
+
message: `Replaced ${searchLines.length} line(s) at line ${originalStartLine}${lineDiff !== 0 ? ` (${lineDiff > 0 ? 'added' : 'removed'} ${Math.abs(lineDiff)} line(s))` : ''}`
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Write back to file
|
|
261
|
+
const newContent = lines.join('\n');
|
|
262
|
+
await FileUtils.writeFile(path, newContent);
|
|
263
|
+
|
|
264
|
+
// Build response
|
|
265
|
+
const failedCount = diffCount - appliedCount;
|
|
266
|
+
const isBatchMode = diffCount > 1;
|
|
267
|
+
|
|
268
|
+
let message;
|
|
269
|
+
if (isBatchMode) {
|
|
270
|
+
const mode = atomic ? "atomic" : "non-atomic";
|
|
271
|
+
message = `Batch diff operation (${mode}) completed: ${appliedCount}/${diffCount} diffs applied successfully to ${path}`;
|
|
272
|
+
if (failedCount > 0) {
|
|
273
|
+
message += ` (${failedCount} failed)`;
|
|
274
|
+
}
|
|
275
|
+
message += `. File now has ${lines.length} lines.\n\n`;
|
|
276
|
+
|
|
277
|
+
// Add detailed results for batch mode
|
|
278
|
+
message += "Detailed results:\n";
|
|
279
|
+
results.forEach((result, index) => {
|
|
280
|
+
message += `\nDiff ${index + 1}:\n`;
|
|
281
|
+
message += ` Status: ${result.status}\n`;
|
|
282
|
+
message += ` Start Line: ${result.start_line}\n`;
|
|
283
|
+
message += ` Message: ${result.message}\n`;
|
|
284
|
+
});
|
|
285
|
+
} else {
|
|
286
|
+
// Single mode - maintain original message format
|
|
287
|
+
const result = results[0];
|
|
288
|
+
if (result.status === "success") {
|
|
289
|
+
message = `Successfully applied diff to ${path}: ${result.message}. File now has ${lines.length} lines.`;
|
|
290
|
+
} else {
|
|
291
|
+
throw new Error(`Single diff failed:\n\n${result.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Create success response using FileUtils
|
|
296
|
+
return FileUtils.createResponse(message);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { FileUtils } from '../utils/fileUtils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* insert_content tool handler
|
|
7
|
+
*/
|
|
8
|
+
export class InsertContentHandler {
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Handle insert_content request
|
|
12
|
+
* @param {Object} args - Request parameters
|
|
13
|
+
* @returns {Promise<Object>} Response result
|
|
14
|
+
*/
|
|
15
|
+
static async handle(args) {
|
|
16
|
+
const { path, line, content } = args;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
// Check if single file or multiple files
|
|
20
|
+
if (Array.isArray(path)) {
|
|
21
|
+
// Insert into multiple files
|
|
22
|
+
return await InsertContentHandler.insertContentMultipleFiles(path, line, content);
|
|
23
|
+
} else {
|
|
24
|
+
// Insert into single file
|
|
25
|
+
return await InsertContentHandler.insertContentSingleFile(path, line, content);
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
throw new Error(`Failed to insert content: ${error.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Insert content into a single file
|
|
34
|
+
* @param {string} filePath - File path
|
|
35
|
+
* @param {number} line - Line number
|
|
36
|
+
* @param {string} content - Content
|
|
37
|
+
* @returns {Promise<Object>} Response result
|
|
38
|
+
*/
|
|
39
|
+
static async insertContentSingleFile(filePath, line, content) {
|
|
40
|
+
const fullPath = FileUtils.getSecurePath(filePath);
|
|
41
|
+
|
|
42
|
+
// Check if file exists
|
|
43
|
+
if (!FileUtils.fileExists(filePath)) {
|
|
44
|
+
throw new Error(`File not found: ${filePath}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Read existing content
|
|
48
|
+
const existingContent = await FileUtils.readFile(filePath);
|
|
49
|
+
const lines = existingContent.split('\n');
|
|
50
|
+
|
|
51
|
+
// Handle insert position
|
|
52
|
+
let insertPosition;
|
|
53
|
+
if (line === 0) {
|
|
54
|
+
// 0 means end of file
|
|
55
|
+
insertPosition = lines.length;
|
|
56
|
+
} else if (line > 0) {
|
|
57
|
+
// Positive: 1-based index
|
|
58
|
+
insertPosition = line - 1; // Convert to 0-based index
|
|
59
|
+
|
|
60
|
+
// Check if insert position is valid
|
|
61
|
+
if (insertPosition > lines.length) {
|
|
62
|
+
throw new Error(`Line number ${line} exceeds file length (${lines.length} lines)`);
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// Negative: calculate insert position from end
|
|
66
|
+
// -1 means before last line, -2 means before second last line, etc.
|
|
67
|
+
insertPosition = lines.length + line;
|
|
68
|
+
|
|
69
|
+
if (insertPosition < 0) {
|
|
70
|
+
throw new Error(`Negative line number ${line} is out of range for file with ${lines.length} lines`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle content to insert (may contain multiple lines)
|
|
75
|
+
const newLines = content.split('\n');
|
|
76
|
+
|
|
77
|
+
// Insert new content (both negative and positive are insert operations)
|
|
78
|
+
lines.splice(insertPosition, 0, ...newLines);
|
|
79
|
+
|
|
80
|
+
// Write back to file
|
|
81
|
+
await fs.writeFile(fullPath, lines.join('\n'), 'utf8');
|
|
82
|
+
|
|
83
|
+
// Build response message
|
|
84
|
+
let positionDesc;
|
|
85
|
+
if (line === 0) {
|
|
86
|
+
positionDesc = 'end of file';
|
|
87
|
+
} else if (line > 0) {
|
|
88
|
+
positionDesc = `line ${line}`;
|
|
89
|
+
} else {
|
|
90
|
+
positionDesc = `line ${Math.abs(line)} from end (before current line ${insertPosition + 1})`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const message = `Successfully inserted ${newLines.length} line(s) at ${positionDesc} in ${filePath}. File now has ${lines.length} lines.`;
|
|
94
|
+
return FileUtils.createResponse(message);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Insert content into multiple files
|
|
99
|
+
* @param {string[]} filePaths - Array of file paths
|
|
100
|
+
* @param {number|number[]} lines - Line number or array of line numbers
|
|
101
|
+
* @param {string|string[]} contents - Content or array of contents
|
|
102
|
+
* @returns {Promise<Object>} Response result
|
|
103
|
+
*/
|
|
104
|
+
static async insertContentMultipleFiles(filePaths, lines, contents) {
|
|
105
|
+
const results = [];
|
|
106
|
+
const errors = [];
|
|
107
|
+
|
|
108
|
+
// Parameter validation
|
|
109
|
+
const fileCount = filePaths.length;
|
|
110
|
+
|
|
111
|
+
// Handle line parameter (can be a single number or array)
|
|
112
|
+
let lineArray;
|
|
113
|
+
if (Array.isArray(lines)) {
|
|
114
|
+
if (lines.length !== fileCount) {
|
|
115
|
+
throw new Error(`Line array length (${lines.length}) must match file count (${fileCount})`);
|
|
116
|
+
}
|
|
117
|
+
lineArray = lines;
|
|
118
|
+
} else {
|
|
119
|
+
// If single number, apply to all files
|
|
120
|
+
lineArray = new Array(fileCount).fill(lines);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Handle content parameter (can be a single string or array)
|
|
124
|
+
let contentArray;
|
|
125
|
+
if (Array.isArray(contents)) {
|
|
126
|
+
if (contents.length !== fileCount) {
|
|
127
|
+
throw new Error(`Content array length (${contents.length}) must match file count (${fileCount})`);
|
|
128
|
+
}
|
|
129
|
+
contentArray = contents;
|
|
130
|
+
} else {
|
|
131
|
+
// If single string, apply to all files
|
|
132
|
+
contentArray = new Array(fileCount).fill(contents);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Perform insert operation for each file
|
|
136
|
+
for (let i = 0; i < fileCount; i++) {
|
|
137
|
+
const filePath = filePaths[i];
|
|
138
|
+
const line = lineArray[i];
|
|
139
|
+
const content = contentArray[i];
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const result = await InsertContentHandler.insertContentSingleFile(filePath, line, content);
|
|
143
|
+
results.push({
|
|
144
|
+
file: filePath,
|
|
145
|
+
success: true,
|
|
146
|
+
message: result.content[0].text
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
errors.push({
|
|
150
|
+
file: filePath,
|
|
151
|
+
error: error.message
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Build return message
|
|
157
|
+
let responseText = '';
|
|
158
|
+
|
|
159
|
+
if (results.length > 0) {
|
|
160
|
+
responseText += `Successfully processed ${results.length} file(s):\n\n`;
|
|
161
|
+
for (const result of results) {
|
|
162
|
+
responseText += `✅ ${result.file}: ${result.message}\n`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (errors.length > 0) {
|
|
167
|
+
responseText += `\nErrors encountered:\n`;
|
|
168
|
+
for (const error of errors) {
|
|
169
|
+
responseText += `❌ ${error.file}: ${error.error}\n`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (results.length === 0 && errors.length > 0) {
|
|
174
|
+
throw new Error(`Failed to insert content in any files: ${errors.map(e => e.error).join(', ')}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return FileUtils.createResponse(responseText.trim());
|
|
178
|
+
}
|
|
179
|
+
}
|