skydeckai-code 0.1.23__py3-none-any.whl
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.
- aidd/__init__.py +11 -0
- aidd/cli.py +141 -0
- aidd/server.py +54 -0
- aidd/tools/__init__.py +155 -0
- aidd/tools/base.py +18 -0
- aidd/tools/code_analysis.py +703 -0
- aidd/tools/code_execution.py +321 -0
- aidd/tools/directory_tools.py +289 -0
- aidd/tools/file_tools.py +784 -0
- aidd/tools/get_active_apps_tool.py +455 -0
- aidd/tools/get_available_windows_tool.py +395 -0
- aidd/tools/git_tools.py +687 -0
- aidd/tools/image_tools.py +127 -0
- aidd/tools/path_tools.py +86 -0
- aidd/tools/screenshot_tool.py +1029 -0
- aidd/tools/state.py +47 -0
- aidd/tools/system_tools.py +190 -0
- skydeckai_code-0.1.23.dist-info/METADATA +628 -0
- skydeckai_code-0.1.23.dist-info/RECORD +22 -0
- skydeckai_code-0.1.23.dist-info/WHEEL +4 -0
- skydeckai_code-0.1.23.dist-info/entry_points.txt +3 -0
- skydeckai_code-0.1.23.dist-info/licenses/LICENSE +201 -0
aidd/tools/file_tools.py
ADDED
@@ -0,0 +1,784 @@
|
|
1
|
+
import difflib
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
import re
|
5
|
+
import stat
|
6
|
+
import subprocess
|
7
|
+
from datetime import datetime
|
8
|
+
from typing import List
|
9
|
+
|
10
|
+
import mcp.types as types
|
11
|
+
|
12
|
+
from .state import state
|
13
|
+
|
14
|
+
|
15
|
+
def read_file_tool():
|
16
|
+
return {
|
17
|
+
"name": "read_file",
|
18
|
+
"description": "Read the complete contents of a file from the file system. "
|
19
|
+
"WHEN TO USE: When you need to examine the actual content of a single file, view source code, check configuration files, or analyze text data. "
|
20
|
+
"This is the primary tool for accessing file contents directly. "
|
21
|
+
"WHEN NOT TO USE: When you only need file metadata like size or modification date (use get_file_info instead), when you need to list directory contents "
|
22
|
+
"(use directory_listing instead), or when you need to read multiple files at once (use read_multiple_files instead). "
|
23
|
+
"RETURNS: The complete text content of the specified file. Binary files or files with unknown encodings will return an error message. "
|
24
|
+
"Handles various text encodings and provides detailed error messages if the file cannot be read. Only works within the allowed directory. "
|
25
|
+
"Example: Enter 'src/main.py' to read a Python file.",
|
26
|
+
"inputSchema": {
|
27
|
+
"type": "object",
|
28
|
+
"properties": {
|
29
|
+
"path": {
|
30
|
+
"type": "string",
|
31
|
+
"description": "Path to the file to read. This must be a path to a file, not a directory. Examples: 'README.md', 'src/main.py', 'config.json'. Both absolute and relative paths are supported, but must be within the allowed workspace.",
|
32
|
+
}
|
33
|
+
},
|
34
|
+
"required": ["path"]
|
35
|
+
},
|
36
|
+
}
|
37
|
+
|
38
|
+
def write_file_tool():
|
39
|
+
return {
|
40
|
+
"name": "write_file",
|
41
|
+
"description": "Create a new file or overwrite an existing file with new content. "
|
42
|
+
"WHEN TO USE: When you need to save changes, create new files, or update existing ones with new content. "
|
43
|
+
"Useful for generating reports, creating configuration files, or saving edited content. "
|
44
|
+
"WHEN NOT TO USE: When you want to make targeted edits to parts of a file while preserving the rest (use edit_file instead), "
|
45
|
+
"when you need to append to a file without overwriting existing content, or when you need to preserve the original file. "
|
46
|
+
"RETURNS: A confirmation message indicating that the file was successfully written. "
|
47
|
+
"Creates parent directories automatically if they don't exist. "
|
48
|
+
"Use with caution as it will overwrite existing files without warning. Only works within the allowed directory. "
|
49
|
+
"Example: Path='notes.txt', Content='Meeting notes for project X'",
|
50
|
+
"inputSchema": {
|
51
|
+
"type": "object",
|
52
|
+
"properties": {
|
53
|
+
"path": {
|
54
|
+
"type": "string",
|
55
|
+
"description": "Path where to write the file. Both absolute and relative paths are supported, but must be within the allowed workspace. Examples: 'README.md', 'logs/debug.log', 'reports/report.txt'. Parent directories will be created automatically if they don't exist."
|
56
|
+
},
|
57
|
+
"content": {
|
58
|
+
"type": "string",
|
59
|
+
"description": "Content to write to the file. The complete text content that should be saved to the file. This will completely replace any existing content if the file already exists."
|
60
|
+
}
|
61
|
+
},
|
62
|
+
"required": ["path", "content"]
|
63
|
+
},
|
64
|
+
}
|
65
|
+
|
66
|
+
def move_file_tool():
|
67
|
+
return {
|
68
|
+
"name": "move_file",
|
69
|
+
"description": "Move or rename a file or directory to a new location. "
|
70
|
+
"WHEN TO USE: When you need to reorganize files or directories, rename files or folders, or move items to a different location within the allowed workspace. "
|
71
|
+
"Useful for organizing project files, restructuring directories, or for simple renaming operations. "
|
72
|
+
"WHEN NOT TO USE: When you want to copy a file while keeping the original (copying functionality is not available in this tool set), "
|
73
|
+
"when destination already exists (the operation will fail), or when either source or destination is outside the allowed workspace. "
|
74
|
+
"RETURNS: A confirmation message indicating that the file or directory was successfully moved. "
|
75
|
+
"Parent directories of the destination will be created automatically if they don't exist. "
|
76
|
+
"Both source and destination must be within the allowed directory. "
|
77
|
+
"Example: source='old.txt', destination='new/path/new.txt'",
|
78
|
+
"inputSchema": {
|
79
|
+
"type": "object",
|
80
|
+
"properties": {
|
81
|
+
"source": {
|
82
|
+
"type": "string",
|
83
|
+
"description": "Source path of the file or directory to move. This file or directory must exist. Both absolute and relative paths are supported, but must be within the allowed workspace. Examples: 'document.txt', 'src/utils.js', 'config/old-settings/'"
|
84
|
+
},
|
85
|
+
"destination": {
|
86
|
+
"type": "string",
|
87
|
+
"description": "Destination path where to move the file or directory. If this path already exists, the operation will fail. Parent directories will be created automatically if they don't exist. Both absolute and relative paths are supported, but must be within the allowed workspace. Examples: 'renamed.txt', 'backup/document.txt', 'src/new-location/'"
|
88
|
+
}
|
89
|
+
},
|
90
|
+
"required": ["source", "destination"]
|
91
|
+
},
|
92
|
+
}
|
93
|
+
|
94
|
+
def search_files_tool():
|
95
|
+
return {
|
96
|
+
"name": "search_files",
|
97
|
+
"description": "Search for files and directories matching a pattern in their names. "
|
98
|
+
"WHEN TO USE: When you need to find files or directories by name pattern across a directory tree, locate files with specific extensions, "
|
99
|
+
"or find items containing certain text in their names. Useful for locating configuration files, finding all files of a certain type, "
|
100
|
+
"or gathering files related to a specific feature. "
|
101
|
+
"WHEN NOT TO USE: When searching for content within files (there is no grep tool in this application), when you need a flat listing of a single directory "
|
102
|
+
"(use directory_listing instead), or when you need to analyze code structure (use codebase_mapper instead). "
|
103
|
+
"RETURNS: A list of matching files and directories with their types ([FILE] or [DIR]) and relative paths. "
|
104
|
+
"For Git repositories, only shows tracked files and directories by default. "
|
105
|
+
"The search is recursive and case-insensitive. Only searches within the allowed directory. "
|
106
|
+
"Example: pattern='.py' finds all Python files, pattern='test' finds all items with 'test' in the name.",
|
107
|
+
"inputSchema": {
|
108
|
+
"type": "object",
|
109
|
+
"properties": {
|
110
|
+
"pattern": {
|
111
|
+
"type": "string",
|
112
|
+
"description": "Pattern to search for in file and directory names. The search is case-insensitive and matches substrings. Examples: '.js' to find all JavaScript files, 'test' to find all items containing 'test' in their name, 'config' to find configuration files and directories."
|
113
|
+
},
|
114
|
+
"path": {
|
115
|
+
"type": "string",
|
116
|
+
"description": "Starting directory for the search (defaults to allowed directory). This is the root directory from which the recursive search begins. Examples: '.' for current directory, 'src' to search only in the src directory tree. Both absolute and relative paths are supported, but must be within the allowed workspace."
|
117
|
+
},
|
118
|
+
"include_hidden": {
|
119
|
+
"type": "boolean",
|
120
|
+
"description": "Whether to include hidden files and directories (defaults to false). On Unix-like systems, hidden items start with a dot (.). Set to true to include them in the search results."
|
121
|
+
}
|
122
|
+
},
|
123
|
+
"required": ["pattern"]
|
124
|
+
},
|
125
|
+
}
|
126
|
+
|
127
|
+
def get_file_info_tool():
|
128
|
+
return {
|
129
|
+
"name": "get_file_info",
|
130
|
+
"description": "Get detailed information about a file or directory. "
|
131
|
+
"WHEN TO USE: When you need to check file metadata like size, timestamps, permissions, or file type without reading the contents. "
|
132
|
+
"Useful for determining when files were modified, checking file sizes, verifying file existence, or distinguishing between files and directories. "
|
133
|
+
"WHEN NOT TO USE: When you need to read the actual content of a file (use read_file instead), or when you need to list all files in a directory (use directory_listing instead). "
|
134
|
+
"RETURNS: Text with information about the file or directory including type (file/directory), size in bytes, creation time, modification time, access time (all in ISO 8601 format), and permissions. "
|
135
|
+
"Only works within the allowed directory. "
|
136
|
+
"Example: path='src/main.py' returns details about main.py",
|
137
|
+
"inputSchema": {
|
138
|
+
"type": "object",
|
139
|
+
"properties": {
|
140
|
+
"path": {
|
141
|
+
"type": "string",
|
142
|
+
"description": "Path to the file or directory to get information about. Can be either a file or directory path. Examples: 'README.md', 'src/components', 'package.json'. Both absolute and relative paths are supported, but must be within the allowed workspace."
|
143
|
+
}
|
144
|
+
},
|
145
|
+
"required": ["path"]
|
146
|
+
},
|
147
|
+
}
|
148
|
+
|
149
|
+
def delete_file_tool():
|
150
|
+
return {
|
151
|
+
"name": "delete_file",
|
152
|
+
"description": "Delete a file or empty directory from the file system. "
|
153
|
+
"WHEN TO USE: When you need to remove unwanted files, clean up temporary files, or delete empty directories. "
|
154
|
+
"Useful for cleaning up workspaces, removing intermediate build artifacts, or deleting temporary files. "
|
155
|
+
"WHEN NOT TO USE: When you need to delete non-empty directories (the operation will fail), when you want to move files instead of deleting them (use move_file instead), "
|
156
|
+
"or when you need to preserve the file for later use. "
|
157
|
+
"RETURNS: A confirmation message indicating that the file or empty directory was successfully deleted. "
|
158
|
+
"For safety, this tool will not delete non-empty directories. "
|
159
|
+
"Use with caution as this operation cannot be undone. Only works within the allowed directory. "
|
160
|
+
"Example: path='old_file.txt' removes the specified file.",
|
161
|
+
"inputSchema": {
|
162
|
+
"type": "object",
|
163
|
+
"properties": {
|
164
|
+
"path": {
|
165
|
+
"type": "string",
|
166
|
+
"description": "Path to the file or empty directory to delete. For directories, they must be completely empty or the operation will fail. Examples: 'temp.txt', 'build/cache.json', 'empty-dir/'. Both absolute and relative paths are supported, but must be within the allowed workspace."
|
167
|
+
}
|
168
|
+
},
|
169
|
+
"required": ["path"]
|
170
|
+
},
|
171
|
+
}
|
172
|
+
|
173
|
+
def read_multiple_files_tool():
|
174
|
+
return {
|
175
|
+
"name": "read_multiple_files",
|
176
|
+
"description": "Read the contents of multiple files simultaneously. "
|
177
|
+
"WHEN TO USE: When you need to examine or compare multiple files at once, analyze related files together, or gather content from several files efficiently. "
|
178
|
+
"Useful for understanding code across multiple files, comparing configuration files, or collecting information from related documents. "
|
179
|
+
"WHEN NOT TO USE: When you only need to read a single file (use read_file instead), when you need to read binary files or images (use read_image_file instead), "
|
180
|
+
"or when you need metadata about files rather than their contents (use get_file_info instead). "
|
181
|
+
"RETURNS: The contents of all specified files, with each file's content preceded by a header showing its path (==> path/to/file <==). "
|
182
|
+
"If an individual file cannot be read, an error message is included in its place, but the operation continues for other files. "
|
183
|
+
"Failed reads for individual files won't stop the entire operation. Only works within the allowed directory. "
|
184
|
+
"Example: Enter ['src/main.py', 'README.md'] to read both files.",
|
185
|
+
"inputSchema": {
|
186
|
+
"type": "object",
|
187
|
+
"properties": {
|
188
|
+
"paths": {
|
189
|
+
"type": "array",
|
190
|
+
"items": {"type": "string"},
|
191
|
+
"description": "List of file paths to read. Must contain at least one path. Each path should point to a text file. Examples: ['README.md', 'package.json'], ['src/main.py', 'src/utils.py', 'config.ini']. Both absolute and relative paths are supported, but must be within the allowed workspace.",
|
192
|
+
}
|
193
|
+
},
|
194
|
+
"required": ["paths"]
|
195
|
+
},
|
196
|
+
}
|
197
|
+
|
198
|
+
def edit_file_tool():
|
199
|
+
return {
|
200
|
+
"name": "edit_file",
|
201
|
+
"description": "Make line-based edits to a text file. "
|
202
|
+
"WHEN TO USE: When you need to make selective changes to specific parts of a file while preserving the rest of the content. "
|
203
|
+
"Useful for modifying configuration values, updating text while maintaining file structure, or making targeted code changes. "
|
204
|
+
"WHEN NOT TO USE: When you want to completely replace a file's contents (use write_file instead), when you need to create a new file (use write_file instead), "
|
205
|
+
"or when you want to apply highly complex edits with context. "
|
206
|
+
"RETURNS: A git-style diff showing the changes made, along with information about any failed matches. "
|
207
|
+
"The response includes sections for failed matches (if any) and the unified diff output. "
|
208
|
+
"Always use dryRun first to preview changes before applying them. Only works within the allowed directory.",
|
209
|
+
"inputSchema": {
|
210
|
+
"type": "object",
|
211
|
+
"properties": {
|
212
|
+
"path": {
|
213
|
+
"type": "string",
|
214
|
+
"description": "File to edit. Must be a text file that exists within the allowed workspace. Examples: 'README.md', 'src/config.js', 'settings.json'. Both absolute and relative paths are supported, but must be within the allowed workspace."
|
215
|
+
},
|
216
|
+
"edits": {
|
217
|
+
"type": "array",
|
218
|
+
"items": {
|
219
|
+
"type": "object",
|
220
|
+
"properties": {
|
221
|
+
"oldText": {
|
222
|
+
"type": "string",
|
223
|
+
"description": "Text to search for in the file. This should be a unique segment of text to identify where the change should be made. Include enough context (lines before/after) to ensure the right match is found."
|
224
|
+
},
|
225
|
+
"newText": {
|
226
|
+
"type": "string",
|
227
|
+
"description": "Text to replace the matched section with. This will completely replace the oldText section. To delete text, use an empty string."
|
228
|
+
}
|
229
|
+
},
|
230
|
+
"required": ["oldText", "newText"]
|
231
|
+
},
|
232
|
+
"description": "List of edit operations to perform on the file. Each edit specifies text to find (oldText) and text to replace it with (newText). The edits are applied in sequence, and each one can modify the result of previous edits."
|
233
|
+
},
|
234
|
+
"dryRun": {
|
235
|
+
"type": "boolean",
|
236
|
+
"description": "Preview changes without applying them to the file. Set to true to see what changes would be made without actually modifying the file. Highly recommended before making actual changes.",
|
237
|
+
"default": False
|
238
|
+
},
|
239
|
+
"options": {
|
240
|
+
"type": "object",
|
241
|
+
"properties": {
|
242
|
+
"preserveIndentation": {
|
243
|
+
"type": "boolean",
|
244
|
+
"description": "Keep existing indentation when replacing text. When true, the indentation of the first line of oldText is preserved in newText.",
|
245
|
+
"default": True
|
246
|
+
},
|
247
|
+
"normalizeWhitespace": {
|
248
|
+
"type": "boolean",
|
249
|
+
"description": "Normalize spaces while preserving structure. When true, consecutive spaces are treated as a single space during matching, making the search more forgiving of whitespace differences.",
|
250
|
+
"default": True
|
251
|
+
},
|
252
|
+
"partialMatch": {
|
253
|
+
"type": "boolean",
|
254
|
+
"description": "Enable fuzzy matching for finding text. When true, the tool will try to find the best match even if it's not an exact match, using a confidence threshold of 80%.",
|
255
|
+
"default": True
|
256
|
+
}
|
257
|
+
}
|
258
|
+
}
|
259
|
+
},
|
260
|
+
"required": ["path", "edits"]
|
261
|
+
}
|
262
|
+
}
|
263
|
+
|
264
|
+
async def _read_single_file(path: str) -> List[types.TextContent]:
|
265
|
+
"""Helper function to read a single file with proper validation."""
|
266
|
+
from mcp.types import TextContent
|
267
|
+
|
268
|
+
# Determine full path based on whether input is absolute or relative
|
269
|
+
if os.path.isabs(path):
|
270
|
+
full_path = os.path.abspath(path) # Just normalize the absolute path
|
271
|
+
else:
|
272
|
+
# For relative paths, join with allowed_directory
|
273
|
+
full_path = os.path.abspath(os.path.join(state.allowed_directory, path))
|
274
|
+
|
275
|
+
if not full_path.startswith(state.allowed_directory):
|
276
|
+
raise ValueError(f"Access denied: Path ({full_path}) must be within allowed directory ({state.allowed_directory})")
|
277
|
+
|
278
|
+
if not os.path.exists(full_path):
|
279
|
+
raise ValueError(f"File does not exist: {full_path}")
|
280
|
+
if not os.path.isfile(full_path):
|
281
|
+
raise ValueError(f"Path is not a file: {full_path}")
|
282
|
+
|
283
|
+
try:
|
284
|
+
with open(full_path, 'r', encoding='utf-8') as f:
|
285
|
+
content = f.read()
|
286
|
+
return [TextContent(
|
287
|
+
type="text",
|
288
|
+
text=content
|
289
|
+
)]
|
290
|
+
except UnicodeDecodeError:
|
291
|
+
raise ValueError(f"File is not a text file or has unknown encoding: {full_path}")
|
292
|
+
except PermissionError:
|
293
|
+
raise ValueError(f"Permission denied reading file: {full_path}")
|
294
|
+
except Exception as e:
|
295
|
+
raise ValueError(f"Error reading file: {str(e)}")
|
296
|
+
|
297
|
+
async def handle_write_file(arguments: dict):
|
298
|
+
"""Handle writing content to a file."""
|
299
|
+
from mcp.types import TextContent
|
300
|
+
|
301
|
+
path = arguments.get("path")
|
302
|
+
content = arguments.get("content")
|
303
|
+
|
304
|
+
if not path:
|
305
|
+
raise ValueError("path must be provided")
|
306
|
+
if content is None:
|
307
|
+
raise ValueError("content must be provided")
|
308
|
+
|
309
|
+
# Determine full path based on whether input is absolute or relative
|
310
|
+
if os.path.isabs(path):
|
311
|
+
full_path = os.path.abspath(path) # Just normalize the absolute path
|
312
|
+
else:
|
313
|
+
# For relative paths, join with allowed_directory
|
314
|
+
full_path = os.path.abspath(os.path.join(state.allowed_directory, path))
|
315
|
+
|
316
|
+
if not full_path.startswith(state.allowed_directory):
|
317
|
+
raise ValueError(f"Access denied: Path ({full_path}) must be within allowed directory ({state.allowed_directory})")
|
318
|
+
|
319
|
+
try:
|
320
|
+
# Create parent directories if they don't exist
|
321
|
+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
322
|
+
|
323
|
+
# Write the file
|
324
|
+
with open(full_path, 'w', encoding='utf-8') as f:
|
325
|
+
f.write(content)
|
326
|
+
|
327
|
+
return [TextContent(
|
328
|
+
type="text",
|
329
|
+
text=f"Successfully wrote to {path}"
|
330
|
+
)]
|
331
|
+
except Exception as e:
|
332
|
+
raise ValueError(f"Error writing file: {str(e)}")
|
333
|
+
|
334
|
+
|
335
|
+
async def handle_read_file(arguments: dict):
|
336
|
+
path = arguments.get("path")
|
337
|
+
if not path:
|
338
|
+
raise ValueError("path must be provided")
|
339
|
+
|
340
|
+
return await _read_single_file(path)
|
341
|
+
|
342
|
+
async def handle_read_multiple_files(arguments: dict):
|
343
|
+
paths = arguments.get("paths", [])
|
344
|
+
if not isinstance(paths, list):
|
345
|
+
raise ValueError("paths must be a list of strings")
|
346
|
+
if not all(isinstance(p, str) for p in paths):
|
347
|
+
raise ValueError("all paths must be strings")
|
348
|
+
if not paths:
|
349
|
+
raise ValueError("paths list cannot be empty")
|
350
|
+
|
351
|
+
from mcp.types import TextContent
|
352
|
+
results = []
|
353
|
+
for path in paths:
|
354
|
+
try:
|
355
|
+
# Add file path header first
|
356
|
+
results.append(TextContent(
|
357
|
+
type="text",
|
358
|
+
text=f"\n==> {path} <==\n"
|
359
|
+
))
|
360
|
+
# Then add file contents
|
361
|
+
file_contents = await _read_single_file(path)
|
362
|
+
results.extend(file_contents)
|
363
|
+
except Exception as e:
|
364
|
+
results.append(TextContent(
|
365
|
+
type="text",
|
366
|
+
text=f"Error: {str(e)}\n"
|
367
|
+
))
|
368
|
+
return results
|
369
|
+
|
370
|
+
async def handle_move_file(arguments: dict):
|
371
|
+
"""Handle moving a file or directory to a new location."""
|
372
|
+
from mcp.types import TextContent
|
373
|
+
|
374
|
+
source = arguments.get("source")
|
375
|
+
destination = arguments.get("destination")
|
376
|
+
|
377
|
+
if not source:
|
378
|
+
raise ValueError("source must be provided")
|
379
|
+
if not destination:
|
380
|
+
raise ValueError("destination must be provided")
|
381
|
+
|
382
|
+
# Determine full paths based on whether inputs are absolute or relative
|
383
|
+
if os.path.isabs(source):
|
384
|
+
full_source = os.path.abspath(source)
|
385
|
+
else:
|
386
|
+
full_source = os.path.abspath(os.path.join(state.allowed_directory, source))
|
387
|
+
|
388
|
+
if os.path.isabs(destination):
|
389
|
+
full_destination = os.path.abspath(destination)
|
390
|
+
else:
|
391
|
+
full_destination = os.path.abspath(os.path.join(state.allowed_directory, destination))
|
392
|
+
|
393
|
+
# Security checks
|
394
|
+
if not full_source.startswith(state.allowed_directory):
|
395
|
+
raise ValueError(f"Access denied: Source path ({full_source}) must be within allowed directory")
|
396
|
+
if not full_destination.startswith(state.allowed_directory):
|
397
|
+
raise ValueError(f"Access denied: Destination path ({full_destination}) must be within allowed directory")
|
398
|
+
|
399
|
+
# Validate source exists
|
400
|
+
if not os.path.exists(full_source):
|
401
|
+
raise ValueError(f"Source path does not exist: {source}")
|
402
|
+
|
403
|
+
# Create parent directories of destination if they don't exist
|
404
|
+
os.makedirs(os.path.dirname(full_destination), exist_ok=True)
|
405
|
+
|
406
|
+
try:
|
407
|
+
# Perform the move operation
|
408
|
+
os.rename(full_source, full_destination)
|
409
|
+
return [TextContent(
|
410
|
+
type="text",
|
411
|
+
text=f"Successfully moved {source} to {destination}"
|
412
|
+
)]
|
413
|
+
except OSError as e:
|
414
|
+
raise ValueError(f"Error moving file: {str(e)}")
|
415
|
+
except Exception as e:
|
416
|
+
raise ValueError(f"Unexpected error: {str(e)}")
|
417
|
+
|
418
|
+
async def handle_search_files(arguments: dict):
|
419
|
+
"""Handle searching for files matching a pattern."""
|
420
|
+
from mcp.types import TextContent
|
421
|
+
|
422
|
+
pattern = arguments.get("pattern")
|
423
|
+
start_path = arguments.get("path", ".")
|
424
|
+
include_hidden = arguments.get("include_hidden", False)
|
425
|
+
|
426
|
+
if not pattern:
|
427
|
+
raise ValueError("pattern must be provided")
|
428
|
+
|
429
|
+
# Determine full path for search start
|
430
|
+
if os.path.isabs(start_path):
|
431
|
+
full_start_path = os.path.abspath(start_path)
|
432
|
+
else:
|
433
|
+
full_start_path = os.path.abspath(os.path.join(state.allowed_directory, start_path))
|
434
|
+
|
435
|
+
# Security check
|
436
|
+
if not full_start_path.startswith(state.allowed_directory):
|
437
|
+
raise ValueError(f"Access denied: Path ({full_start_path}) must be within allowed directory")
|
438
|
+
|
439
|
+
if not os.path.exists(full_start_path):
|
440
|
+
raise ValueError(f"Start path does not exist: {start_path}")
|
441
|
+
if not os.path.isdir(full_start_path):
|
442
|
+
raise ValueError(f"Start path is not a directory: {start_path}")
|
443
|
+
|
444
|
+
matches = []
|
445
|
+
pattern = pattern.lower() # Case-insensitive search
|
446
|
+
|
447
|
+
# Try git ls-files first
|
448
|
+
try:
|
449
|
+
# First, check if this is a git repository and get tracked files
|
450
|
+
result = subprocess.run(
|
451
|
+
['git', 'ls-files'],
|
452
|
+
cwd=full_start_path,
|
453
|
+
capture_output=True,
|
454
|
+
text=True,
|
455
|
+
check=True
|
456
|
+
)
|
457
|
+
|
458
|
+
# Also get git directories (excluding .git itself)
|
459
|
+
dirs_result = subprocess.run(
|
460
|
+
['git', 'ls-tree', '-d', '-r', '--name-only', 'HEAD'],
|
461
|
+
cwd=full_start_path,
|
462
|
+
capture_output=True,
|
463
|
+
text=True,
|
464
|
+
check=True
|
465
|
+
)
|
466
|
+
|
467
|
+
# Process git-tracked files
|
468
|
+
files = result.stdout.splitlines()
|
469
|
+
dirs = dirs_result.stdout.splitlines()
|
470
|
+
|
471
|
+
# Process directories first
|
472
|
+
for dir_path in dirs:
|
473
|
+
if pattern in dir_path.lower():
|
474
|
+
if include_hidden or not any(part.startswith('.') for part in dir_path.split(os.sep)):
|
475
|
+
matches.append(f"[DIR] {dir_path}")
|
476
|
+
|
477
|
+
# Then process files
|
478
|
+
for file_path in files:
|
479
|
+
if pattern in file_path.lower():
|
480
|
+
if include_hidden or not any(part.startswith('.') for part in file_path.split(os.sep)):
|
481
|
+
matches.append(f"[FILE] {file_path}")
|
482
|
+
|
483
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
484
|
+
# Fallback to regular directory walk if git is not available or not a git repository
|
485
|
+
try:
|
486
|
+
for root, dirs, files in os.walk(full_start_path):
|
487
|
+
# Get paths relative to allowed directory
|
488
|
+
rel_root = os.path.relpath(root, state.allowed_directory)
|
489
|
+
|
490
|
+
# Skip hidden directories if not included
|
491
|
+
if not include_hidden:
|
492
|
+
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
493
|
+
|
494
|
+
# Process directories
|
495
|
+
for dir_name in dirs:
|
496
|
+
if pattern in dir_name.lower():
|
497
|
+
rel_path = os.path.join(rel_root, dir_name)
|
498
|
+
if include_hidden or not any(part.startswith('.') for part in rel_path.split(os.sep)):
|
499
|
+
matches.append(f"[DIR] {rel_path}")
|
500
|
+
|
501
|
+
# Process files
|
502
|
+
for file_name in files:
|
503
|
+
if pattern in file_name.lower():
|
504
|
+
rel_path = os.path.join(rel_root, file_name)
|
505
|
+
if include_hidden or not any(part.startswith('.') for part in rel_path.split(os.sep)):
|
506
|
+
matches.append(f"[FILE] {rel_path}")
|
507
|
+
|
508
|
+
except Exception as e:
|
509
|
+
raise ValueError(f"Error searching files: {str(e)}")
|
510
|
+
|
511
|
+
# Sort matches for consistent output
|
512
|
+
matches.sort()
|
513
|
+
|
514
|
+
if not matches:
|
515
|
+
return [TextContent(
|
516
|
+
type="text",
|
517
|
+
text="No matches found"
|
518
|
+
)]
|
519
|
+
|
520
|
+
return [TextContent(
|
521
|
+
type="text",
|
522
|
+
text="\n".join(matches)
|
523
|
+
)]
|
524
|
+
|
525
|
+
async def handle_get_file_info(arguments: dict):
|
526
|
+
"""Handle getting detailed information about a file or directory."""
|
527
|
+
from mcp.types import TextContent
|
528
|
+
|
529
|
+
path = arguments.get("path")
|
530
|
+
if not path:
|
531
|
+
raise ValueError("path must be provided")
|
532
|
+
|
533
|
+
# Determine full path
|
534
|
+
if os.path.isabs(path):
|
535
|
+
full_path = os.path.abspath(path)
|
536
|
+
else:
|
537
|
+
full_path = os.path.abspath(os.path.join(state.allowed_directory, path))
|
538
|
+
|
539
|
+
# Security check
|
540
|
+
if not full_path.startswith(state.allowed_directory):
|
541
|
+
raise ValueError(f"Access denied: Path ({full_path}) must be within allowed directory")
|
542
|
+
|
543
|
+
if not os.path.exists(full_path):
|
544
|
+
raise ValueError(f"Path does not exist: {path}")
|
545
|
+
|
546
|
+
try:
|
547
|
+
stat_info = os.stat(full_path)
|
548
|
+
|
549
|
+
# Format file type
|
550
|
+
file_type = "directory" if os.path.isdir(full_path) else "file"
|
551
|
+
|
552
|
+
# Format permissions in octal
|
553
|
+
perms = stat.filemode(stat_info.st_mode)
|
554
|
+
|
555
|
+
info = f"""Type: {file_type}
|
556
|
+
Size: {stat_info.st_size:,} bytes
|
557
|
+
Created: {datetime.fromtimestamp(stat_info.st_ctime).isoformat()}
|
558
|
+
Modified: {datetime.fromtimestamp(stat_info.st_mtime).isoformat()}
|
559
|
+
Accessed: {datetime.fromtimestamp(stat_info.st_atime).isoformat()}
|
560
|
+
Permissions: {perms}"""
|
561
|
+
|
562
|
+
return [TextContent(type="text", text=info)]
|
563
|
+
|
564
|
+
except Exception as e:
|
565
|
+
raise ValueError(f"Error getting file info: {str(e)}")
|
566
|
+
|
567
|
+
async def handle_delete_file(arguments: dict):
|
568
|
+
"""Handle deleting a file or empty directory."""
|
569
|
+
from mcp.types import TextContent
|
570
|
+
|
571
|
+
path = arguments.get("path")
|
572
|
+
if not path:
|
573
|
+
raise ValueError("path must be provided")
|
574
|
+
|
575
|
+
# Determine full path
|
576
|
+
if os.path.isabs(path):
|
577
|
+
full_path = os.path.abspath(path)
|
578
|
+
else:
|
579
|
+
full_path = os.path.abspath(os.path.join(state.allowed_directory, path))
|
580
|
+
|
581
|
+
# Security check
|
582
|
+
if not full_path.startswith(state.allowed_directory):
|
583
|
+
raise ValueError(f"Access denied: Path ({full_path}) must be within allowed directory")
|
584
|
+
|
585
|
+
if not os.path.exists(full_path):
|
586
|
+
raise ValueError(f"Path does not exist: {path}")
|
587
|
+
|
588
|
+
try:
|
589
|
+
if os.path.isdir(full_path):
|
590
|
+
# Check if directory is empty
|
591
|
+
if os.listdir(full_path):
|
592
|
+
raise ValueError(f"Cannot delete non-empty directory: {path}")
|
593
|
+
os.rmdir(full_path)
|
594
|
+
return [TextContent(
|
595
|
+
type="text",
|
596
|
+
text=f"Successfully deleted empty directory: {path}"
|
597
|
+
)]
|
598
|
+
else:
|
599
|
+
os.remove(full_path)
|
600
|
+
return [TextContent(
|
601
|
+
type="text",
|
602
|
+
text=f"Successfully deleted file: {path}"
|
603
|
+
)]
|
604
|
+
except Exception as e:
|
605
|
+
raise ValueError(f"Error deleting {path}: {str(e)}")
|
606
|
+
|
607
|
+
def normalize_whitespace(text: str, preserve_indentation: bool = True) -> str:
|
608
|
+
"""Normalize whitespace while optionally preserving indentation."""
|
609
|
+
lines = text.splitlines()
|
610
|
+
normalized_lines = []
|
611
|
+
|
612
|
+
for line in lines:
|
613
|
+
if preserve_indentation:
|
614
|
+
# Preserve leading whitespace
|
615
|
+
indent = re.match(r'^\s*', line).group(0)
|
616
|
+
# Normalize other whitespace
|
617
|
+
content = re.sub(r'\s+', ' ', line.lstrip())
|
618
|
+
normalized_lines.append(f"{indent}{content}")
|
619
|
+
else:
|
620
|
+
# Normalize all whitespace
|
621
|
+
normalized_lines.append(re.sub(r'\s+', ' ', line.strip()))
|
622
|
+
|
623
|
+
return '\n'.join(normalized_lines)
|
624
|
+
|
625
|
+
def create_unified_diff(original: str, modified: str, filepath: str) -> str:
|
626
|
+
"""Create a unified diff between two texts."""
|
627
|
+
original_lines = original.splitlines()
|
628
|
+
modified_lines = modified.splitlines()
|
629
|
+
|
630
|
+
diff = difflib.unified_diff(
|
631
|
+
original_lines,
|
632
|
+
modified_lines,
|
633
|
+
fromfile=f'a/{filepath}',
|
634
|
+
tofile=f'b/{filepath}',
|
635
|
+
n=0, # No context lines needed for single line changes
|
636
|
+
lineterm='' # Don't add newlines here
|
637
|
+
)
|
638
|
+
|
639
|
+
# Join lines with single newlines and strip any extra whitespace
|
640
|
+
return '\n'.join(line.rstrip() for line in diff)
|
641
|
+
|
642
|
+
def find_substring_position(content: str, pattern: str) -> tuple[int, int]:
|
643
|
+
"""Find the position of a substring in content."""
|
644
|
+
pos = content.find(pattern)
|
645
|
+
if pos >= 0:
|
646
|
+
return pos, pos + len(pattern)
|
647
|
+
return -1, -1
|
648
|
+
|
649
|
+
def find_best_match(content: str, pattern: str, partial_match: bool = True) -> tuple[int, int, float]:
|
650
|
+
"""Find the best matching position for a pattern in content."""
|
651
|
+
if not partial_match:
|
652
|
+
# Exact matching only
|
653
|
+
pos = content.find(pattern)
|
654
|
+
if pos >= 0:
|
655
|
+
return pos, pos + len(pattern), 1.0
|
656
|
+
return -1, -1, 0.0
|
657
|
+
|
658
|
+
# Try exact substring match first
|
659
|
+
start, end = find_substring_position(content, pattern)
|
660
|
+
if start >= 0:
|
661
|
+
return start, end, 1.0
|
662
|
+
|
663
|
+
# If no exact substring match, try line-based fuzzy matching
|
664
|
+
|
665
|
+
# Split into lines for line-based matching
|
666
|
+
content_lines = content.splitlines()
|
667
|
+
pattern_lines = pattern.splitlines()
|
668
|
+
|
669
|
+
best_score = 0.0
|
670
|
+
best_start = -1
|
671
|
+
best_end = -1
|
672
|
+
|
673
|
+
for i in range(len(content_lines) - len(pattern_lines) + 1):
|
674
|
+
# Compare each potential match position
|
675
|
+
window = content_lines[i:i + len(pattern_lines)]
|
676
|
+
score = sum(difflib.SequenceMatcher(None, a, b).ratio()
|
677
|
+
for a, b in zip(window, pattern_lines)) / len(pattern_lines)
|
678
|
+
|
679
|
+
if score > best_score:
|
680
|
+
best_score = score
|
681
|
+
best_start = sum(len(line) + 1 for line in content_lines[:i])
|
682
|
+
best_end = best_start + sum(len(line) + 1 for line in window)
|
683
|
+
|
684
|
+
return best_start, best_end, best_score
|
685
|
+
|
686
|
+
async def apply_file_edits(file_path: str, edits: List[dict], dry_run: bool = False, options: dict = None) -> str:
|
687
|
+
"""Apply edits to a file with optional formatting and return diff."""
|
688
|
+
# Set default options
|
689
|
+
options = options or {}
|
690
|
+
preserve_indentation = options.get('preserveIndentation', True)
|
691
|
+
normalize_ws = options.get('normalizeWhitespace', True)
|
692
|
+
partial_match = options.get('partialMatch', True)
|
693
|
+
|
694
|
+
# Read file content
|
695
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
696
|
+
content = f.read()
|
697
|
+
|
698
|
+
# Track modifications
|
699
|
+
modified_content = content
|
700
|
+
failed_matches = []
|
701
|
+
|
702
|
+
# Apply each edit
|
703
|
+
for edit in edits:
|
704
|
+
old_text = edit['oldText']
|
705
|
+
new_text = edit['newText']
|
706
|
+
|
707
|
+
# Normalize texts if requested
|
708
|
+
if normalize_ws:
|
709
|
+
search_text = normalize_whitespace(old_text, preserve_indentation)
|
710
|
+
working_content = normalize_whitespace(modified_content, preserve_indentation)
|
711
|
+
else:
|
712
|
+
search_text = old_text
|
713
|
+
working_content = modified_content
|
714
|
+
|
715
|
+
# Find best match
|
716
|
+
start, end, confidence = find_best_match(working_content, search_text, partial_match)
|
717
|
+
|
718
|
+
if confidence >= 0.8:
|
719
|
+
# Preserve indentation of first line if requested
|
720
|
+
if preserve_indentation and start >= 0:
|
721
|
+
indent = re.match(r'^\s*', modified_content[start:].splitlines()[0]).group(0)
|
722
|
+
replacement = '\n'.join(indent + line.lstrip()
|
723
|
+
for line in new_text.splitlines())
|
724
|
+
else:
|
725
|
+
replacement = new_text
|
726
|
+
|
727
|
+
# Apply the edit
|
728
|
+
modified_content = modified_content[:start] + replacement + modified_content[end:]
|
729
|
+
else:
|
730
|
+
failed_matches.append({
|
731
|
+
'oldText': old_text,
|
732
|
+
'confidence': confidence,
|
733
|
+
'bestMatch': working_content[start:end] if start >= 0 and end > start else None
|
734
|
+
})
|
735
|
+
|
736
|
+
# Create diff
|
737
|
+
diff = create_unified_diff(content, modified_content, os.path.basename(file_path))
|
738
|
+
|
739
|
+
# Write changes if not dry run
|
740
|
+
if not dry_run and not failed_matches:
|
741
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
742
|
+
f.write(modified_content)
|
743
|
+
|
744
|
+
# Return results
|
745
|
+
failed_matches_text = '=== Failed Matches ===\n' + json.dumps(failed_matches, indent=2) + '\n\n' if failed_matches else ''
|
746
|
+
diff_text = f'=== Diff ===\n{diff}'
|
747
|
+
return failed_matches_text + diff_text
|
748
|
+
|
749
|
+
async def handle_edit_file(arguments: dict):
|
750
|
+
"""Handle editing a file with pattern matching and formatting."""
|
751
|
+
from mcp.types import TextContent
|
752
|
+
|
753
|
+
path = arguments.get("path")
|
754
|
+
edits = arguments.get("edits")
|
755
|
+
dry_run = arguments.get("dryRun", False)
|
756
|
+
options = arguments.get("options", {})
|
757
|
+
|
758
|
+
if not path:
|
759
|
+
raise ValueError("path must be provided")
|
760
|
+
if not edits or not isinstance(edits, list):
|
761
|
+
raise ValueError("edits must be a non-empty list")
|
762
|
+
|
763
|
+
# Validate edits structure
|
764
|
+
for edit in edits:
|
765
|
+
if not isinstance(edit, dict):
|
766
|
+
raise ValueError("each edit must be an object")
|
767
|
+
if 'oldText' not in edit or 'newText' not in edit:
|
768
|
+
raise ValueError("each edit must have oldText and newText properties")
|
769
|
+
|
770
|
+
# Determine full path and validate
|
771
|
+
if os.path.isabs(path):
|
772
|
+
full_path = os.path.abspath(path)
|
773
|
+
else:
|
774
|
+
full_path = os.path.abspath(os.path.join(state.allowed_directory, path))
|
775
|
+
|
776
|
+
if not full_path.startswith(state.allowed_directory):
|
777
|
+
raise ValueError(f"Access denied: Path ({full_path}) must be within allowed directory ({state.allowed_directory})")
|
778
|
+
|
779
|
+
try:
|
780
|
+
result = await apply_file_edits(full_path, edits, dry_run, options)
|
781
|
+
return [TextContent(type="text", text=result)]
|
782
|
+
except Exception as e:
|
783
|
+
raise ValueError(f"Error editing file: {str(e)}")
|
784
|
+
|