cade-cli 0.3.3__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.
- cade_cli-0.3.3.dist-info/METADATA +151 -0
- cade_cli-0.3.3.dist-info/RECORD +44 -0
- cade_cli-0.3.3.dist-info/WHEEL +4 -0
- cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
- cadecoder/__init__.py +1 -0
- cadecoder/ai/__init__.py +6 -0
- cadecoder/ai/prompts.py +572 -0
- cadecoder/cli/__init__.py +0 -0
- cadecoder/cli/app.py +147 -0
- cadecoder/cli/auth.py +483 -0
- cadecoder/cli/commands/__init__.py +5 -0
- cadecoder/cli/commands/auth.py +143 -0
- cadecoder/cli/commands/chat.py +264 -0
- cadecoder/cli/commands/mcp.py +477 -0
- cadecoder/cli/commands/tools.py +226 -0
- cadecoder/core/__init__.py +12 -0
- cadecoder/core/config.py +380 -0
- cadecoder/core/constants.py +281 -0
- cadecoder/core/errors.py +145 -0
- cadecoder/core/logging.py +148 -0
- cadecoder/core/types.py +235 -0
- cadecoder/core/utils.py +279 -0
- cadecoder/execution/__init__.py +46 -0
- cadecoder/execution/context_window.py +521 -0
- cadecoder/execution/orchestrator.py +562 -0
- cadecoder/execution/parallel.py +287 -0
- cadecoder/providers/__init__.py +60 -0
- cadecoder/providers/base.py +294 -0
- cadecoder/providers/openai.py +251 -0
- cadecoder/storage/__init__.py +0 -0
- cadecoder/storage/threads.py +489 -0
- cadecoder/templates/login_failed.html +21 -0
- cadecoder/templates/login_success.html +21 -0
- cadecoder/templates/styles.css +87 -0
- cadecoder/tools/__init__.py +19 -0
- cadecoder/tools/builtin.py +644 -0
- cadecoder/tools/filesystem.py +315 -0
- cadecoder/tools/git.py +221 -0
- cadecoder/tools/manager.py +1635 -0
- cadecoder/ui/__init__.py +7 -0
- cadecoder/ui/display.py +338 -0
- cadecoder/ui/input.py +145 -0
- cadecoder/ui/session.py +455 -0
- cadecoder/ui/state.py +20 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""Builtin tools for CadeCoder."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Annotated, Any, Literal
|
|
9
|
+
|
|
10
|
+
from arcade_tdk import ToolContext, tool
|
|
11
|
+
from arcade_tdk.errors import ToolExecutionError
|
|
12
|
+
|
|
13
|
+
from cadecoder.core.constants import (
|
|
14
|
+
DEFAULT_IGNORE_PATTERNS,
|
|
15
|
+
MAX_LIST_DEPTH,
|
|
16
|
+
MAX_LIST_RESULTS,
|
|
17
|
+
MAX_PREVIEW_BYTES,
|
|
18
|
+
MODE_APPEND,
|
|
19
|
+
MODE_OVERWRITE,
|
|
20
|
+
)
|
|
21
|
+
from cadecoder.core.logging import log
|
|
22
|
+
from cadecoder.tools.filesystem import (
|
|
23
|
+
generate_diff_from_content,
|
|
24
|
+
read_text_file,
|
|
25
|
+
write_text_file,
|
|
26
|
+
)
|
|
27
|
+
from cadecoder.tools.git import get_current_branch_name, get_status
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_project_root() -> pathlib.Path:
|
|
31
|
+
"""Get the project root directory."""
|
|
32
|
+
return pathlib.Path.cwd()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
PROJECT_ROOT = get_project_root()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PathResolver:
|
|
39
|
+
"""Resolve and validate paths safely."""
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def resolve_safe_path(path_str: str, base_dir: pathlib.Path) -> pathlib.Path:
|
|
43
|
+
"""Resolve a path safely within a base directory."""
|
|
44
|
+
path = pathlib.Path(path_str)
|
|
45
|
+
if path.is_absolute():
|
|
46
|
+
resolved = path.resolve()
|
|
47
|
+
else:
|
|
48
|
+
resolved = (base_dir / path).resolve()
|
|
49
|
+
return resolved
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --- Helper Functions ---
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _should_ignore(path: pathlib.Path, ignore_patterns: list[str]) -> bool:
|
|
56
|
+
"""Check if a path should be ignored based on patterns."""
|
|
57
|
+
path_str = str(path)
|
|
58
|
+
name = path.name
|
|
59
|
+
|
|
60
|
+
for pattern in ignore_patterns:
|
|
61
|
+
if pattern.startswith("*."):
|
|
62
|
+
if name.endswith(pattern[1:]):
|
|
63
|
+
return True
|
|
64
|
+
elif "*" in pattern:
|
|
65
|
+
if fnmatch.fnmatch(name, pattern):
|
|
66
|
+
return True
|
|
67
|
+
else:
|
|
68
|
+
if pattern in path_str.split("/"):
|
|
69
|
+
return True
|
|
70
|
+
if name == pattern:
|
|
71
|
+
return True
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- File Management Tools ---
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@tool(
|
|
79
|
+
name="Local_ListFiles",
|
|
80
|
+
desc="List files in a directory (recursively up to a depth limit).",
|
|
81
|
+
)
|
|
82
|
+
def list_files_tool(
|
|
83
|
+
context: ToolContext,
|
|
84
|
+
directory: Annotated[
|
|
85
|
+
str | None,
|
|
86
|
+
"Directory path to list (relative to project root). Defaults to current directory.",
|
|
87
|
+
] = ".",
|
|
88
|
+
recursive: Annotated[bool, "Whether to list files recursively."] = True,
|
|
89
|
+
depth: Annotated[
|
|
90
|
+
int, "Maximum depth for listing files. 0 means no depth limit."
|
|
91
|
+
] = MAX_LIST_DEPTH,
|
|
92
|
+
) -> Annotated[dict, "Output containing a list of files and an optional message."]:
|
|
93
|
+
"""Lists files and directories within a specified path."""
|
|
94
|
+
if not 0 <= depth <= MAX_LIST_DEPTH:
|
|
95
|
+
raise ToolExecutionError(f"Depth must be between 0 and {MAX_LIST_DEPTH}")
|
|
96
|
+
|
|
97
|
+
base_path = PathResolver.resolve_safe_path(directory or ".", PROJECT_ROOT)
|
|
98
|
+
if not base_path.is_dir():
|
|
99
|
+
raise ToolExecutionError(f"Not a directory: {directory or '.'}")
|
|
100
|
+
|
|
101
|
+
listed_paths: list[str] = []
|
|
102
|
+
if not recursive:
|
|
103
|
+
for item in base_path.iterdir():
|
|
104
|
+
try:
|
|
105
|
+
if _should_ignore(item, DEFAULT_IGNORE_PATTERNS):
|
|
106
|
+
continue
|
|
107
|
+
relative_to_project = item.relative_to(PROJECT_ROOT)
|
|
108
|
+
listed_paths.append(str(relative_to_project))
|
|
109
|
+
if len(listed_paths) >= MAX_LIST_RESULTS:
|
|
110
|
+
break
|
|
111
|
+
except ValueError:
|
|
112
|
+
continue
|
|
113
|
+
except OSError as e:
|
|
114
|
+
log.warning(f"Error processing path {item}: {e}")
|
|
115
|
+
continue
|
|
116
|
+
else:
|
|
117
|
+
queue: list[tuple[pathlib.Path, int]] = [
|
|
118
|
+
(child, 0)
|
|
119
|
+
for child in base_path.iterdir()
|
|
120
|
+
if not _should_ignore(child, DEFAULT_IGNORE_PATTERNS)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
while queue:
|
|
124
|
+
if len(listed_paths) >= MAX_LIST_RESULTS:
|
|
125
|
+
break
|
|
126
|
+
current_item, item_depth = queue.pop(0)
|
|
127
|
+
|
|
128
|
+
if item_depth > depth:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
relative_to_project = current_item.relative_to(PROJECT_ROOT)
|
|
133
|
+
listed_paths.append(str(relative_to_project))
|
|
134
|
+
|
|
135
|
+
if current_item.is_dir() and item_depth < depth:
|
|
136
|
+
for child in current_item.iterdir():
|
|
137
|
+
if not _should_ignore(child, DEFAULT_IGNORE_PATTERNS):
|
|
138
|
+
queue.append((child, item_depth + 1))
|
|
139
|
+
except ValueError:
|
|
140
|
+
continue
|
|
141
|
+
except OSError as e:
|
|
142
|
+
log.warning(f"Error processing path {current_item}: {e}")
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
message = None
|
|
146
|
+
if len(listed_paths) >= MAX_LIST_RESULTS:
|
|
147
|
+
message = f"... (truncated at {MAX_LIST_RESULTS} entries)"
|
|
148
|
+
|
|
149
|
+
listed_paths.sort()
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"files": listed_paths,
|
|
153
|
+
"message": message if message else "Files listed successfully.",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@tool(
|
|
158
|
+
name="Local_ReadFile",
|
|
159
|
+
desc="Read the entire content of a text file from the workspace.",
|
|
160
|
+
)
|
|
161
|
+
def read_file_tool(
|
|
162
|
+
context: ToolContext,
|
|
163
|
+
file_path_arg: Annotated[str, "Path to the file to read (relative or absolute)."],
|
|
164
|
+
) -> Annotated[dict, "Output containing the file content and an optional message."]:
|
|
165
|
+
"""Reads content of a specified file."""
|
|
166
|
+
file_path = PathResolver.resolve_safe_path(file_path_arg, PROJECT_ROOT)
|
|
167
|
+
if not file_path.is_file():
|
|
168
|
+
raise ToolExecutionError(f"File not found: {file_path_arg} (resolved: {file_path})")
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
size = file_path.stat().st_size
|
|
172
|
+
if size > MAX_PREVIEW_BYTES:
|
|
173
|
+
log.warning(f"File {file_path_arg} exceeds preview limit. Truncating.")
|
|
174
|
+
with file_path.open("rb") as f:
|
|
175
|
+
start_bytes = f.read(MAX_PREVIEW_BYTES // 2)
|
|
176
|
+
f.seek(max(0, size - MAX_PREVIEW_BYTES // 2))
|
|
177
|
+
end_bytes = f.read(MAX_PREVIEW_BYTES // 2)
|
|
178
|
+
start_str = start_bytes.decode("utf-8", errors="replace")
|
|
179
|
+
end_str = end_bytes.decode("utf-8", errors="replace")
|
|
180
|
+
content = start_str + f"\n... (file truncated, size: {size} bytes) ...\n" + end_str
|
|
181
|
+
return {"content": content, "message": "File was truncated due to size."}
|
|
182
|
+
else:
|
|
183
|
+
content = read_text_file(file_path)
|
|
184
|
+
return {"content": content, "message": "File read successfully."}
|
|
185
|
+
except OSError as e:
|
|
186
|
+
raise ToolExecutionError(f"Error accessing file {file_path_arg}: {e}") from e
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@tool(
|
|
190
|
+
name="Local_WriteFile",
|
|
191
|
+
desc="Write content to a file, creating/overwriting or appending.",
|
|
192
|
+
)
|
|
193
|
+
def write_file_tool(
|
|
194
|
+
context: ToolContext,
|
|
195
|
+
file_path_arg: Annotated[str, "Path of the file to write."],
|
|
196
|
+
content: Annotated[str, "The complete content to write into the file."],
|
|
197
|
+
mode: Annotated[
|
|
198
|
+
Literal["overwrite", "append"],
|
|
199
|
+
f"How to write: '{MODE_OVERWRITE}' to replace all content, '{MODE_APPEND}' to add to the end.",
|
|
200
|
+
] = "overwrite",
|
|
201
|
+
) -> Annotated[dict, "Result of the write operation."]:
|
|
202
|
+
"""Writes content to a file."""
|
|
203
|
+
resolved_path = PathResolver.resolve_safe_path(file_path_arg, PROJECT_ROOT)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
if mode == MODE_APPEND and resolved_path.exists():
|
|
207
|
+
existing_content = read_text_file(resolved_path)
|
|
208
|
+
content = existing_content + content
|
|
209
|
+
|
|
210
|
+
write_text_file(resolved_path, content)
|
|
211
|
+
return {
|
|
212
|
+
"success": True,
|
|
213
|
+
"message": f"Successfully wrote to {file_path_arg}",
|
|
214
|
+
}
|
|
215
|
+
except Exception as e:
|
|
216
|
+
raise ToolExecutionError(f"Failed to write file {file_path_arg}: {e}") from e
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --- Command Execution Tools ---
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@tool(name="Local_ExecuteCommand", desc="Execute a shell command. Use with caution.")
|
|
223
|
+
def execute_command_tool(
|
|
224
|
+
context: ToolContext,
|
|
225
|
+
command: Annotated[str, "The shell command to execute."],
|
|
226
|
+
cwd: Annotated[
|
|
227
|
+
str | None,
|
|
228
|
+
"Working directory for the command (relative to project root).",
|
|
229
|
+
] = None,
|
|
230
|
+
timeout: Annotated[int, "Timeout for the command in seconds."] = 30,
|
|
231
|
+
) -> Annotated[dict, "Output of the command execution."]:
|
|
232
|
+
"""Executes a shell command."""
|
|
233
|
+
if cwd:
|
|
234
|
+
working_dir = PathResolver.resolve_safe_path(cwd, PROJECT_ROOT)
|
|
235
|
+
if not working_dir.is_dir():
|
|
236
|
+
raise ToolExecutionError(f"Working directory not found: {cwd}")
|
|
237
|
+
else:
|
|
238
|
+
working_dir = PROJECT_ROOT
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
result = subprocess.run(
|
|
242
|
+
command,
|
|
243
|
+
shell=True,
|
|
244
|
+
cwd=str(working_dir),
|
|
245
|
+
capture_output=True,
|
|
246
|
+
text=True,
|
|
247
|
+
timeout=timeout if timeout > 0 else None,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"stdout": result.stdout if result.stdout else "no output",
|
|
252
|
+
"stderr": result.stderr if result.stderr else "no stderr",
|
|
253
|
+
"exit_code": result.returncode,
|
|
254
|
+
"success": result.returncode == 0,
|
|
255
|
+
}
|
|
256
|
+
except subprocess.TimeoutExpired:
|
|
257
|
+
raise ToolExecutionError(f"Command timed out after {timeout} seconds")
|
|
258
|
+
except Exception as e:
|
|
259
|
+
log.error(f"Command execution failed: {e}")
|
|
260
|
+
raise ToolExecutionError(f"Command execution failed: {e}") from e
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --- Search Tool (Simple ripgrep-based) ---
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@tool(
|
|
267
|
+
name="Local_SearchCode",
|
|
268
|
+
desc="Search for files containing specific text or patterns using ripgrep.",
|
|
269
|
+
)
|
|
270
|
+
def search_code_tool(
|
|
271
|
+
context: ToolContext,
|
|
272
|
+
pattern: Annotated[str, "Text or regex pattern to search for."],
|
|
273
|
+
directory: Annotated[str, "Directory to search in (relative to project root)."] = ".",
|
|
274
|
+
file_extensions: Annotated[
|
|
275
|
+
list[str] | None,
|
|
276
|
+
"File extensions to search (e.g., ['py', 'js']). None searches all.",
|
|
277
|
+
] = None,
|
|
278
|
+
case_sensitive: Annotated[bool, "Whether search should be case sensitive."] = False,
|
|
279
|
+
max_results: Annotated[int, "Maximum number of results to return."] = 100,
|
|
280
|
+
) -> Annotated[dict[str, Any], "Search results with file paths and matching lines."]:
|
|
281
|
+
"""Searches for files containing specific patterns using ripgrep."""
|
|
282
|
+
if not pattern or not pattern.strip():
|
|
283
|
+
raise ToolExecutionError("Search pattern cannot be empty.")
|
|
284
|
+
|
|
285
|
+
safe_dir = PathResolver.resolve_safe_path(directory, PROJECT_ROOT)
|
|
286
|
+
if not safe_dir.exists() or not safe_dir.is_dir():
|
|
287
|
+
return {
|
|
288
|
+
"results": [],
|
|
289
|
+
"summary": {
|
|
290
|
+
"total_matches": 0,
|
|
291
|
+
"error": f"Directory '{directory}' not found or not a directory",
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# Build ripgrep command
|
|
296
|
+
rg_cmd = ["rg", "--json", "-m", str(max_results)]
|
|
297
|
+
if not case_sensitive:
|
|
298
|
+
rg_cmd.append("-i")
|
|
299
|
+
if file_extensions:
|
|
300
|
+
for ext in file_extensions:
|
|
301
|
+
rg_cmd.extend(["-g", f"*.{ext.lstrip('.')}"])
|
|
302
|
+
|
|
303
|
+
# Add ignore patterns
|
|
304
|
+
for ignore in DEFAULT_IGNORE_PATTERNS:
|
|
305
|
+
rg_cmd.extend(["-g", f"!{ignore}"])
|
|
306
|
+
|
|
307
|
+
rg_cmd.append(pattern)
|
|
308
|
+
rg_cmd.append(str(safe_dir))
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
result = subprocess.run(
|
|
312
|
+
rg_cmd,
|
|
313
|
+
capture_output=True,
|
|
314
|
+
text=True,
|
|
315
|
+
timeout=30,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
results = []
|
|
319
|
+
files_with_matches = set()
|
|
320
|
+
|
|
321
|
+
for line in result.stdout.strip().split("\n"):
|
|
322
|
+
if not line:
|
|
323
|
+
continue
|
|
324
|
+
try:
|
|
325
|
+
data = json.loads(line)
|
|
326
|
+
if data.get("type") == "match":
|
|
327
|
+
match_data = data.get("data", {})
|
|
328
|
+
path = match_data.get("path", {}).get("text", "")
|
|
329
|
+
line_num = match_data.get("line_number", 0)
|
|
330
|
+
line_text = match_data.get("lines", {}).get("text", "").strip()
|
|
331
|
+
|
|
332
|
+
# Make path relative
|
|
333
|
+
try:
|
|
334
|
+
rel_path = str(pathlib.Path(path).relative_to(PROJECT_ROOT))
|
|
335
|
+
except ValueError:
|
|
336
|
+
rel_path = path
|
|
337
|
+
|
|
338
|
+
results.append(
|
|
339
|
+
{
|
|
340
|
+
"file": rel_path,
|
|
341
|
+
"line": line_num,
|
|
342
|
+
"content": line_text,
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
files_with_matches.add(rel_path)
|
|
346
|
+
except json.JSONDecodeError:
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
"results": results,
|
|
351
|
+
"summary": {
|
|
352
|
+
"total_matches": len(results),
|
|
353
|
+
"files_with_matches": len(files_with_matches),
|
|
354
|
+
"pattern_searched": pattern,
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
except FileNotFoundError:
|
|
358
|
+
# ripgrep not installed, fall back to grep
|
|
359
|
+
log.warning("ripgrep not found, falling back to grep")
|
|
360
|
+
return _search_with_grep(pattern, safe_dir, case_sensitive, max_results)
|
|
361
|
+
except subprocess.TimeoutExpired:
|
|
362
|
+
raise ToolExecutionError("Search timed out")
|
|
363
|
+
except Exception as e:
|
|
364
|
+
log.error(f"Search failed: {e}")
|
|
365
|
+
raise ToolExecutionError(f"Search failed: {e}") from e
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _search_with_grep(
|
|
369
|
+
pattern: str,
|
|
370
|
+
directory: pathlib.Path,
|
|
371
|
+
case_sensitive: bool,
|
|
372
|
+
max_results: int,
|
|
373
|
+
) -> dict[str, Any]:
|
|
374
|
+
"""Fallback search using grep."""
|
|
375
|
+
grep_cmd = ["grep", "-rn"]
|
|
376
|
+
if not case_sensitive:
|
|
377
|
+
grep_cmd.append("-i")
|
|
378
|
+
grep_cmd.extend([pattern, str(directory)])
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
result = subprocess.run(
|
|
382
|
+
grep_cmd,
|
|
383
|
+
capture_output=True,
|
|
384
|
+
text=True,
|
|
385
|
+
timeout=30,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
results = []
|
|
389
|
+
files_with_matches = set()
|
|
390
|
+
|
|
391
|
+
for line in result.stdout.strip().split("\n")[:max_results]:
|
|
392
|
+
if not line:
|
|
393
|
+
continue
|
|
394
|
+
# Parse grep output: file:line:content
|
|
395
|
+
parts = line.split(":", 2)
|
|
396
|
+
if len(parts) >= 3:
|
|
397
|
+
try:
|
|
398
|
+
rel_path = str(pathlib.Path(parts[0]).relative_to(PROJECT_ROOT))
|
|
399
|
+
except ValueError:
|
|
400
|
+
rel_path = parts[0]
|
|
401
|
+
|
|
402
|
+
results.append(
|
|
403
|
+
{
|
|
404
|
+
"file": rel_path,
|
|
405
|
+
"line": int(parts[1]) if parts[1].isdigit() else 0,
|
|
406
|
+
"content": parts[2].strip(),
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
files_with_matches.add(rel_path)
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
"results": results,
|
|
413
|
+
"summary": {
|
|
414
|
+
"total_matches": len(results),
|
|
415
|
+
"files_with_matches": len(files_with_matches),
|
|
416
|
+
"pattern_searched": pattern,
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
except Exception as e:
|
|
420
|
+
return {
|
|
421
|
+
"results": [],
|
|
422
|
+
"summary": {"total_matches": 0, "error": str(e)},
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# --- Git Tools ---
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@tool(name="Local_GitStatus", desc="Check git status of the repository.")
|
|
430
|
+
def git_status_tool(
|
|
431
|
+
context: ToolContext,
|
|
432
|
+
) -> Annotated[dict, "Git status information."]:
|
|
433
|
+
"""Gets the current git status."""
|
|
434
|
+
stdout, stderr = get_status()
|
|
435
|
+
|
|
436
|
+
if stderr:
|
|
437
|
+
raise ToolExecutionError(f"Git status failed: {stderr}")
|
|
438
|
+
|
|
439
|
+
files = []
|
|
440
|
+
for line in stdout.split("\n"):
|
|
441
|
+
if line.strip():
|
|
442
|
+
if len(line) >= 3:
|
|
443
|
+
status = line[:2]
|
|
444
|
+
filename = line[3:].strip()
|
|
445
|
+
files.append({"status": status, "file": filename})
|
|
446
|
+
|
|
447
|
+
branch_stdout, branch_stderr = get_current_branch_name()
|
|
448
|
+
current_branch = branch_stdout if not branch_stderr else "unknown"
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
"branch": current_branch,
|
|
452
|
+
"files": files if files else "no files",
|
|
453
|
+
"clean": len(files) == 0,
|
|
454
|
+
"raw_output": stdout if stdout else "no output",
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# --- Edit File Tool ---
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@tool(
|
|
462
|
+
name="Local_EditFile",
|
|
463
|
+
desc="Edit a file by replacing specific text. Use for targeted changes.",
|
|
464
|
+
)
|
|
465
|
+
def edit_file_tool(
|
|
466
|
+
context: ToolContext,
|
|
467
|
+
file_path_arg: Annotated[str, "Path to the file to edit."],
|
|
468
|
+
old_string: Annotated[
|
|
469
|
+
str,
|
|
470
|
+
"The exact text to find and replace. Must match exactly including whitespace.",
|
|
471
|
+
],
|
|
472
|
+
new_string: Annotated[str, "The text to replace old_string with."],
|
|
473
|
+
expected_count: Annotated[
|
|
474
|
+
int | None,
|
|
475
|
+
"Expected number of replacements. If provided, fails if count doesn't match.",
|
|
476
|
+
] = None,
|
|
477
|
+
) -> Annotated[dict, "Result of the edit operation including diff."]:
|
|
478
|
+
"""Edit a file by finding and replacing specific text.
|
|
479
|
+
|
|
480
|
+
This tool performs precise text replacement in files. The old_string must
|
|
481
|
+
match exactly (including whitespace and indentation). Use this for targeted
|
|
482
|
+
edits rather than full file rewrites.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
context: Tool context
|
|
486
|
+
file_path_arg: Path to file to edit
|
|
487
|
+
old_string: Exact text to find
|
|
488
|
+
new_string: Text to replace with
|
|
489
|
+
expected_count: Optional expected replacement count for validation
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Dict with success status, diff, and replacement count
|
|
493
|
+
"""
|
|
494
|
+
resolved_path = PathResolver.resolve_safe_path(file_path_arg, PROJECT_ROOT)
|
|
495
|
+
|
|
496
|
+
if not resolved_path.is_file():
|
|
497
|
+
raise ToolExecutionError(f"File not found: {file_path_arg}")
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
original_content = read_text_file(resolved_path)
|
|
501
|
+
except Exception as e:
|
|
502
|
+
raise ToolExecutionError(f"Failed to read file: {e}") from e
|
|
503
|
+
|
|
504
|
+
# Count occurrences
|
|
505
|
+
count = original_content.count(old_string)
|
|
506
|
+
|
|
507
|
+
if count == 0:
|
|
508
|
+
# Provide helpful context for debugging
|
|
509
|
+
preview_len = 100
|
|
510
|
+
old_preview = old_string[:preview_len]
|
|
511
|
+
if len(old_string) > preview_len:
|
|
512
|
+
old_preview += "..."
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
"success": False,
|
|
516
|
+
"error": "old_string not found in file",
|
|
517
|
+
"old_string_preview": old_preview,
|
|
518
|
+
"file_size": len(original_content),
|
|
519
|
+
"suggestion": "Check whitespace, indentation, and exact character match",
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if expected_count is not None and count != expected_count:
|
|
523
|
+
return {
|
|
524
|
+
"success": False,
|
|
525
|
+
"error": f"Expected {expected_count} occurrences but found {count}",
|
|
526
|
+
"actual_count": count,
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# Perform replacement
|
|
530
|
+
new_content = original_content.replace(old_string, new_string)
|
|
531
|
+
|
|
532
|
+
# Generate diff for review
|
|
533
|
+
diff = generate_diff_from_content(
|
|
534
|
+
original_content,
|
|
535
|
+
new_content,
|
|
536
|
+
filename=file_path_arg,
|
|
537
|
+
context_lines=3,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Write the file
|
|
541
|
+
try:
|
|
542
|
+
write_text_file(resolved_path, new_content)
|
|
543
|
+
except Exception as e:
|
|
544
|
+
raise ToolExecutionError(f"Failed to write file: {e}") from e
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
"success": True,
|
|
548
|
+
"message": f"Successfully edited {file_path_arg}",
|
|
549
|
+
"replacements": count,
|
|
550
|
+
"diff": diff if diff else "(no visible diff - content may be identical)",
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@tool(
|
|
555
|
+
name="Local_EditFileInsert",
|
|
556
|
+
desc="Insert text at a specific line number in a file.",
|
|
557
|
+
)
|
|
558
|
+
def edit_file_insert_tool(
|
|
559
|
+
context: ToolContext,
|
|
560
|
+
file_path_arg: Annotated[str, "Path to the file to edit."],
|
|
561
|
+
line_number: Annotated[int, "Line number to insert at (1-indexed). 0 = end of file."],
|
|
562
|
+
content: Annotated[str, "The text to insert."],
|
|
563
|
+
position: Annotated[
|
|
564
|
+
Literal["before", "after"],
|
|
565
|
+
"Insert before or after the specified line.",
|
|
566
|
+
] = "before",
|
|
567
|
+
) -> Annotated[dict, "Result of the insert operation including diff."]:
|
|
568
|
+
"""Insert text at a specific line in a file.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
context: Tool context
|
|
572
|
+
file_path_arg: Path to file to edit
|
|
573
|
+
line_number: Line number (1-indexed) to insert at. 0 means end of file.
|
|
574
|
+
content: Text to insert
|
|
575
|
+
position: Insert 'before' or 'after' the specified line
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
Dict with success status and diff
|
|
579
|
+
"""
|
|
580
|
+
resolved_path = PathResolver.resolve_safe_path(file_path_arg, PROJECT_ROOT)
|
|
581
|
+
|
|
582
|
+
if not resolved_path.is_file():
|
|
583
|
+
raise ToolExecutionError(f"File not found: {file_path_arg}")
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
original_content = read_text_file(resolved_path)
|
|
587
|
+
except Exception as e:
|
|
588
|
+
raise ToolExecutionError(f"Failed to read file: {e}") from e
|
|
589
|
+
|
|
590
|
+
lines = original_content.splitlines(keepends=True)
|
|
591
|
+
|
|
592
|
+
# Handle line number
|
|
593
|
+
if line_number == 0:
|
|
594
|
+
# Append to end
|
|
595
|
+
if lines and not lines[-1].endswith("\n"):
|
|
596
|
+
lines[-1] += "\n"
|
|
597
|
+
lines.append(content if content.endswith("\n") else content + "\n")
|
|
598
|
+
elif line_number < 0 or line_number > len(lines) + 1:
|
|
599
|
+
raise ToolExecutionError(
|
|
600
|
+
f"Line number {line_number} out of range (file has {len(lines)} lines)"
|
|
601
|
+
)
|
|
602
|
+
else:
|
|
603
|
+
# Convert to 0-indexed
|
|
604
|
+
idx = line_number - 1
|
|
605
|
+
insert_content = content if content.endswith("\n") else content + "\n"
|
|
606
|
+
|
|
607
|
+
if position == "before":
|
|
608
|
+
lines.insert(idx, insert_content)
|
|
609
|
+
else: # after
|
|
610
|
+
lines.insert(idx + 1, insert_content)
|
|
611
|
+
|
|
612
|
+
new_content = "".join(lines)
|
|
613
|
+
|
|
614
|
+
# Generate diff
|
|
615
|
+
diff = generate_diff_from_content(
|
|
616
|
+
original_content,
|
|
617
|
+
new_content,
|
|
618
|
+
filename=file_path_arg,
|
|
619
|
+
context_lines=3,
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
# Write the file
|
|
623
|
+
try:
|
|
624
|
+
write_text_file(resolved_path, new_content)
|
|
625
|
+
except Exception as e:
|
|
626
|
+
raise ToolExecutionError(f"Failed to write file: {e}") from e
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
"success": True,
|
|
630
|
+
"message": f"Successfully inserted content at line {line_number}",
|
|
631
|
+
"diff": diff,
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
# --- Tool Registration Helper ---
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def get_all_tools() -> list[Callable]:
|
|
639
|
+
"""Returns a list of all tool functions defined in this module."""
|
|
640
|
+
tool_functions = []
|
|
641
|
+
for name, obj in globals().items():
|
|
642
|
+
if callable(obj) and hasattr(obj, "__tool_name__") and hasattr(obj, "__tool_description__"):
|
|
643
|
+
tool_functions.append(obj)
|
|
644
|
+
return tool_functions
|