aizen-ai-cli 2.2.2__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.
- aizen/__init__.py +4 -0
- aizen/commands.py +694 -0
- aizen/config.py +363 -0
- aizen/context.py +171 -0
- aizen/exceptions.py +46 -0
- aizen/logging_config.py +65 -0
- aizen/main.py +616 -0
- aizen/mcp.py +110 -0
- aizen/plugins.py +63 -0
- aizen/retry.py +133 -0
- aizen/session.py +137 -0
- aizen/tools.py +1035 -0
- aizen/utils.py +339 -0
- aizen_ai_cli-2.2.2.dist-info/METADATA +267 -0
- aizen_ai_cli-2.2.2.dist-info/RECORD +18 -0
- aizen_ai_cli-2.2.2.dist-info/WHEEL +5 -0
- aizen_ai_cli-2.2.2.dist-info/entry_points.txt +2 -0
- aizen_ai_cli-2.2.2.dist-info/top_level.txt +1 -0
aizen/tools.py
ADDED
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import fnmatch
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.syntax import Syntax
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from .config import DANGEROUS_PATTERNS, SAFE_COMMAND_PREFIXES, console
|
|
18
|
+
from .logging_config import logger
|
|
19
|
+
from .plugins import plugin_manager
|
|
20
|
+
from .utils import BackupManager, load_gitignore_patterns, should_ignore, truncate_output
|
|
21
|
+
|
|
22
|
+
# Global dictionary for tracking background tasks
|
|
23
|
+
# task_id -> {"process": Popen, "stdout": list, "stderr": list, "command": str}
|
|
24
|
+
background_tasks: dict[str, dict[str, Any]] = {}
|
|
25
|
+
background_tasks_lock = threading.Lock() # Protects background_tasks dict
|
|
26
|
+
|
|
27
|
+
terminal_lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
def fuzzy_match_file(filepath: str) -> str | None:
|
|
30
|
+
"""
|
|
31
|
+
If the exact filepath does not exist, searches the current directory tree
|
|
32
|
+
for a close match. Returns the matched path or None.
|
|
33
|
+
"""
|
|
34
|
+
if not filepath or filepath.startswith("/") or filepath.startswith("~"):
|
|
35
|
+
return None # Only fuzzy match relative paths safely
|
|
36
|
+
|
|
37
|
+
ignore_patterns = load_gitignore_patterns()
|
|
38
|
+
all_files = []
|
|
39
|
+
|
|
40
|
+
# Collect all available files in the tree
|
|
41
|
+
for root, dirs, files in os.walk("."):
|
|
42
|
+
dirs[:] = [d for d in dirs if not should_ignore(os.path.join(root, d), ignore_patterns)]
|
|
43
|
+
for f in files:
|
|
44
|
+
path = os.path.relpath(os.path.join(root, f), ".")
|
|
45
|
+
if not should_ignore(path, ignore_patterns):
|
|
46
|
+
all_files.append(path)
|
|
47
|
+
|
|
48
|
+
# Use difflib to find the closest match
|
|
49
|
+
matches = difflib.get_close_matches(filepath, all_files, n=1, cutoff=0.75)
|
|
50
|
+
return matches[0] if matches else None
|
|
51
|
+
|
|
52
|
+
# ─── Constants ──────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
MAX_FILE_SIZE_BYTES = 1_048_576 # 1 MB — refuse to read files larger than this
|
|
55
|
+
MAX_FILE_SIZE_WARNING = 512_000 # 512 KB — warn but allow
|
|
56
|
+
BINARY_EXTENSIONS = frozenset({
|
|
57
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg",
|
|
58
|
+
".mp3", ".mp4", ".wav", ".avi", ".mov", ".mkv",
|
|
59
|
+
".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
|
|
60
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
61
|
+
".exe", ".dll", ".so", ".dylib", ".bin", ".dat",
|
|
62
|
+
".pyc", ".pyo", ".class", ".o", ".obj",
|
|
63
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
64
|
+
".sqlite", ".db",
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
# ─── Tools Definition ──────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
tools = [
|
|
70
|
+
{
|
|
71
|
+
"type": "function",
|
|
72
|
+
"function": {
|
|
73
|
+
"name": "read_file",
|
|
74
|
+
"description": "Reads the contents of a file. Use this to understand code before making changes.",
|
|
75
|
+
"parameters": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"filepath": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"description": "Path to the file to read.",
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"required": ["filepath"],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"type": "function",
|
|
89
|
+
"function": {
|
|
90
|
+
"name": "write_file",
|
|
91
|
+
"description": "Creates a new file or fully overwrites an existing one. For modifying existing files, prefer edit_file instead.",
|
|
92
|
+
"parameters": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
"filepath": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "Path to the file to create/overwrite.",
|
|
98
|
+
},
|
|
99
|
+
"content": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"description": "The full content to write.",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
"required": ["filepath", "content"],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"type": "function",
|
|
110
|
+
"function": {
|
|
111
|
+
"name": "edit_file",
|
|
112
|
+
"description": "Makes a surgical edit to an existing file by replacing a specific block of text with new text. Always use this instead of write_file when modifying existing files. The old_content must match exactly.",
|
|
113
|
+
"parameters": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {
|
|
116
|
+
"filepath": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"description": "Path to the file to edit.",
|
|
119
|
+
},
|
|
120
|
+
"old_content": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"description": "The exact existing text block to find and replace. Must match the file content exactly.",
|
|
123
|
+
},
|
|
124
|
+
"new_content": {
|
|
125
|
+
"type": "string",
|
|
126
|
+
"description": "The replacement text.",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
"required": ["filepath", "old_content", "new_content"],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"type": "function",
|
|
135
|
+
"function": {
|
|
136
|
+
"name": "run_command",
|
|
137
|
+
"description": "Executes a shell command. Safe read-only commands run automatically; destructive commands require user confirmation. Use the timeout parameter for long-running commands like builds or test suites.",
|
|
138
|
+
"parameters": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"command": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"description": "The shell command to execute.",
|
|
144
|
+
},
|
|
145
|
+
"timeout": {
|
|
146
|
+
"type": "integer",
|
|
147
|
+
"description": "Timeout in seconds. Default 120. Set higher for builds/tests (e.g. 300).",
|
|
148
|
+
},
|
|
149
|
+
"background": {
|
|
150
|
+
"type": "boolean",
|
|
151
|
+
"description": "If true, runs the command asynchronously in the background. Returns a task ID immediately.",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
"required": ["command"],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"type": "function",
|
|
160
|
+
"function": {
|
|
161
|
+
"name": "check_background_task",
|
|
162
|
+
"description": "Checks the status and reads the recent output of a command running in the background.",
|
|
163
|
+
"parameters": {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"properties": {
|
|
166
|
+
"task_id": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"description": "The ID of the background task.",
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"required": ["task_id"],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"type": "function",
|
|
177
|
+
"function": {
|
|
178
|
+
"name": "kill_background_task",
|
|
179
|
+
"description": "Kills a running background task.",
|
|
180
|
+
"parameters": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"properties": {
|
|
183
|
+
"task_id": {
|
|
184
|
+
"type": "string",
|
|
185
|
+
"description": "The ID of the background task.",
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"required": ["task_id"],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"type": "function",
|
|
194
|
+
"function": {
|
|
195
|
+
"name": "list_directory",
|
|
196
|
+
"description": "Lists files and folders in a directory, respecting .gitignore patterns. Shows file sizes.",
|
|
197
|
+
"parameters": {
|
|
198
|
+
"type": "object",
|
|
199
|
+
"properties": {
|
|
200
|
+
"path": {
|
|
201
|
+
"type": "string",
|
|
202
|
+
"description": "Directory path to list (defaults to '.').",
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"type": "function",
|
|
210
|
+
"function": {
|
|
211
|
+
"name": "grep_search",
|
|
212
|
+
"description": "Searches for a text or regex pattern in files under a directory. Returns matching lines with file paths and line numbers.",
|
|
213
|
+
"parameters": {
|
|
214
|
+
"type": "object",
|
|
215
|
+
"properties": {
|
|
216
|
+
"query": {
|
|
217
|
+
"type": "string",
|
|
218
|
+
"description": "The text or regex pattern to search for.",
|
|
219
|
+
},
|
|
220
|
+
"path": {
|
|
221
|
+
"type": "string",
|
|
222
|
+
"description": "Directory to search in (defaults to '.').",
|
|
223
|
+
},
|
|
224
|
+
"is_regex": {
|
|
225
|
+
"type": "boolean",
|
|
226
|
+
"description": "If true, treats query as a regex pattern. Default: false.",
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
"required": ["query"],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
"type": "function",
|
|
235
|
+
"function": {
|
|
236
|
+
"name": "find_files",
|
|
237
|
+
"description": "Finds files by name pattern (glob) across the workspace. Use this to locate files when you don't know the exact path.",
|
|
238
|
+
"parameters": {
|
|
239
|
+
"type": "object",
|
|
240
|
+
"properties": {
|
|
241
|
+
"pattern": {
|
|
242
|
+
"type": "string",
|
|
243
|
+
"description": "Glob pattern to match filenames (e.g., '*.py', 'test_*.js', 'Dockerfile').",
|
|
244
|
+
},
|
|
245
|
+
"path": {
|
|
246
|
+
"type": "string",
|
|
247
|
+
"description": "Root directory to search from (defaults to '.').",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
"required": ["pattern"],
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ─── Helpers ────────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
def _is_binary_file(filepath: str) -> bool:
|
|
260
|
+
"""Check if a file is likely binary based on extension."""
|
|
261
|
+
_, ext = os.path.splitext(filepath.lower())
|
|
262
|
+
return ext in BINARY_EXTENSIONS
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _detect_language(filepath: str) -> str:
|
|
266
|
+
"""Detect Rich Syntax language from file extension for diff highlighting."""
|
|
267
|
+
ext_map = {
|
|
268
|
+
".py": "python", ".js": "javascript", ".ts": "typescript",
|
|
269
|
+
".jsx": "jsx", ".tsx": "tsx", ".html": "html", ".css": "css",
|
|
270
|
+
".json": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml",
|
|
271
|
+
".md": "markdown", ".rs": "rust", ".go": "go", ".java": "java",
|
|
272
|
+
".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
|
|
273
|
+
".rb": "ruby", ".php": "php", ".sh": "bash", ".bash": "bash",
|
|
274
|
+
".zsh": "bash", ".sql": "sql", ".xml": "xml", ".swift": "swift",
|
|
275
|
+
".kt": "kotlin", ".scala": "scala", ".r": "r",
|
|
276
|
+
".dockerfile": "dockerfile", ".tf": "hcl",
|
|
277
|
+
}
|
|
278
|
+
_, ext = os.path.splitext(filepath.lower())
|
|
279
|
+
basename = os.path.basename(filepath).lower()
|
|
280
|
+
if basename in ("dockerfile", "makefile", "gemfile", "rakefile"):
|
|
281
|
+
return basename
|
|
282
|
+
return ext_map.get(ext, "text")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _render_diff(diff_lines: list[str], filepath: str) -> None:
|
|
286
|
+
"""Render a unified diff with rich terminal formatting."""
|
|
287
|
+
diff_text = Text()
|
|
288
|
+
|
|
289
|
+
for line in diff_lines:
|
|
290
|
+
line = line.rstrip("\n")
|
|
291
|
+
if not line:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
295
|
+
diff_text.append(line + "\n", style="bold cyan")
|
|
296
|
+
elif line.startswith("@@"):
|
|
297
|
+
diff_text.append(line + "\n", style="cyan")
|
|
298
|
+
elif line.startswith("+"):
|
|
299
|
+
# Green text on a very dark green background
|
|
300
|
+
diff_text.append(line + "\n", style="green on #0e2a14")
|
|
301
|
+
elif line.startswith("-"):
|
|
302
|
+
# Red text on a very dark red background
|
|
303
|
+
diff_text.append(line + "\n", style="red on #3b1414")
|
|
304
|
+
else:
|
|
305
|
+
diff_text.append(line + "\n", style="dim")
|
|
306
|
+
|
|
307
|
+
if len(diff_text) > 0:
|
|
308
|
+
console.print(diff_text)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _try_repair_json(raw: str) -> dict | None:
|
|
312
|
+
"""
|
|
313
|
+
Attempt to repair common JSON issues from LLM tool calls:
|
|
314
|
+
- Trailing commas
|
|
315
|
+
- Single quotes
|
|
316
|
+
- Unquoted keys
|
|
317
|
+
"""
|
|
318
|
+
# Try as-is first
|
|
319
|
+
try:
|
|
320
|
+
return json.loads(raw)
|
|
321
|
+
except json.JSONDecodeError:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
# Strip trailing commas before } or ]
|
|
325
|
+
repaired = re.sub(r",\s*([}\]])", r"\1", raw)
|
|
326
|
+
try:
|
|
327
|
+
return json.loads(repaired)
|
|
328
|
+
except json.JSONDecodeError:
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
# Replace single quotes with double quotes (naive, but catches simple cases)
|
|
332
|
+
repaired = raw.replace("'", '"')
|
|
333
|
+
repaired = re.sub(r",\s*([}\]])", r"\1", repaired)
|
|
334
|
+
try:
|
|
335
|
+
return json.loads(repaired)
|
|
336
|
+
except json.JSONDecodeError:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ─── Tool Implementations ──────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
backup_manager = BackupManager()
|
|
345
|
+
|
|
346
|
+
_git_warned = False
|
|
347
|
+
|
|
348
|
+
def _check_git_dirty(filepath: str) -> None:
|
|
349
|
+
"""Warn the user once per session if they are modifying files in a dirty git repo."""
|
|
350
|
+
global _git_warned
|
|
351
|
+
if _git_warned:
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
abs_dir = os.path.dirname(os.path.abspath(filepath))
|
|
356
|
+
# Check if we are in a git repo
|
|
357
|
+
repo_dir = subprocess.run(
|
|
358
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
359
|
+
cwd=abs_dir, capture_output=True, text=True, check=True
|
|
360
|
+
).stdout.strip()
|
|
361
|
+
|
|
362
|
+
# Check if dirty
|
|
363
|
+
status = subprocess.run(
|
|
364
|
+
["git", "status", "--porcelain"],
|
|
365
|
+
cwd=repo_dir, capture_output=True, text=True, check=True
|
|
366
|
+
).stdout.strip()
|
|
367
|
+
|
|
368
|
+
if status:
|
|
369
|
+
console.print(
|
|
370
|
+
"\n [bold yellow]⚠️ Git Safety Warning:[/bold yellow] "
|
|
371
|
+
"[yellow]You have uncommitted changes in this repository.[/yellow]\n"
|
|
372
|
+
" [dim]Aizen's modifications could mix with your uncommitted work.\n"
|
|
373
|
+
" Consider committing or stashing your changes before proceeding.[/dim]\n"
|
|
374
|
+
)
|
|
375
|
+
_git_warned = True
|
|
376
|
+
except Exception:
|
|
377
|
+
pass # Not a git repo or git not installed
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def read_file(filepath: str) -> str:
|
|
381
|
+
"""Read file contents with safety checks for size and binary detection."""
|
|
382
|
+
logger.debug("read_file: %s", filepath)
|
|
383
|
+
try:
|
|
384
|
+
if not os.path.exists(filepath):
|
|
385
|
+
match = fuzzy_match_file(filepath)
|
|
386
|
+
if match:
|
|
387
|
+
console.print(f" [dim yellow]⚠️ File '{filepath}' not found, fuzzy matched to '{match}'[/dim yellow]")
|
|
388
|
+
filepath = match
|
|
389
|
+
else:
|
|
390
|
+
return f"Error: File '{filepath}' does not exist."
|
|
391
|
+
|
|
392
|
+
# Binary file check
|
|
393
|
+
if _is_binary_file(filepath):
|
|
394
|
+
return (
|
|
395
|
+
f"Error: '{filepath}' appears to be a binary file. "
|
|
396
|
+
f"Binary files cannot be read as text."
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# File size check
|
|
400
|
+
file_size = os.path.getsize(filepath)
|
|
401
|
+
if file_size > MAX_FILE_SIZE_BYTES:
|
|
402
|
+
size_mb = file_size / (1024 * 1024)
|
|
403
|
+
return (
|
|
404
|
+
f"Error: File '{filepath}' is too large ({size_mb:.1f} MB). "
|
|
405
|
+
f"Maximum allowed size is {MAX_FILE_SIZE_BYTES // (1024 * 1024)} MB. "
|
|
406
|
+
f"Use `run_command` with `head -n 100 {filepath}` to preview."
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
if file_size > MAX_FILE_SIZE_WARNING:
|
|
410
|
+
size_kb = file_size / 1024
|
|
411
|
+
console.print(
|
|
412
|
+
f" [yellow]⚠️ Large file: {filepath} ({size_kb:.0f} KB)[/yellow]"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
with open(filepath, encoding="utf-8", errors="ignore") as f:
|
|
416
|
+
content = f.read()
|
|
417
|
+
|
|
418
|
+
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
|
419
|
+
return f"[File: {filepath} | {line_count} lines | {file_size:,} bytes]\n{content}"
|
|
420
|
+
except PermissionError:
|
|
421
|
+
logger.error("Permission denied reading '%s'", filepath)
|
|
422
|
+
return f"Error: Permission denied reading '{filepath}'."
|
|
423
|
+
except Exception as e:
|
|
424
|
+
logger.exception("Error reading file '%s'", filepath)
|
|
425
|
+
return f"Error reading file: {e}"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def write_file_with_diff(filepath: str, content: str, auto_approve: bool = False) -> str:
|
|
429
|
+
"""Write/overwrite a file with diff preview and optional auto-approval."""
|
|
430
|
+
logger.debug("write_file: %s (%d bytes)", filepath, len(content))
|
|
431
|
+
try:
|
|
432
|
+
_check_git_dirty(filepath)
|
|
433
|
+
old_content = ""
|
|
434
|
+
exists = os.path.exists(filepath)
|
|
435
|
+
if not exists:
|
|
436
|
+
match = fuzzy_match_file(filepath)
|
|
437
|
+
if match:
|
|
438
|
+
console.print(f" [dim yellow]⚠️ File '{filepath}' not found, fuzzy matched to '{match}'[/dim yellow]")
|
|
439
|
+
filepath = match
|
|
440
|
+
exists = True
|
|
441
|
+
|
|
442
|
+
if exists:
|
|
443
|
+
try:
|
|
444
|
+
with open(filepath, encoding="utf-8", errors="ignore") as f:
|
|
445
|
+
old_content = f.read()
|
|
446
|
+
except Exception as e:
|
|
447
|
+
logger.debug("Failed to read old content for %s: %s", filepath, e)
|
|
448
|
+
|
|
449
|
+
if exists:
|
|
450
|
+
diff = list(
|
|
451
|
+
difflib.unified_diff(
|
|
452
|
+
old_content.splitlines(keepends=True),
|
|
453
|
+
content.splitlines(keepends=True),
|
|
454
|
+
fromfile=f"a/{filepath}",
|
|
455
|
+
tofile=f"b/{filepath}",
|
|
456
|
+
n=3,
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
if not diff:
|
|
460
|
+
return f"No changes to write for {filepath}"
|
|
461
|
+
|
|
462
|
+
console.print(
|
|
463
|
+
Panel(
|
|
464
|
+
f"[bold magenta]Aizen wants to overwrite:[/bold magenta] [cyan]{filepath}[/cyan]",
|
|
465
|
+
border_style="magenta",
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
_render_diff(diff, filepath)
|
|
469
|
+
else:
|
|
470
|
+
preview_lines = content.split("\n")[:15]
|
|
471
|
+
preview = "\n".join(preview_lines)
|
|
472
|
+
total_lines = len(content.split("\n"))
|
|
473
|
+
if total_lines > 15:
|
|
474
|
+
preview += f"\n... ({total_lines} total lines)"
|
|
475
|
+
|
|
476
|
+
console.print(
|
|
477
|
+
Panel(
|
|
478
|
+
f"[bold magenta]Aizen wants to create:[/bold magenta] [cyan]{filepath}[/cyan]",
|
|
479
|
+
border_style="magenta",
|
|
480
|
+
)
|
|
481
|
+
)
|
|
482
|
+
lang = _detect_language(filepath)
|
|
483
|
+
syntax = Syntax(preview, lang, theme="monokai", line_numbers=True)
|
|
484
|
+
console.print(syntax)
|
|
485
|
+
|
|
486
|
+
# YOLO mode: skip confirmation
|
|
487
|
+
if not auto_approve:
|
|
488
|
+
with terminal_lock:
|
|
489
|
+
confirmation = input(" Allow? (y/n): ").strip().lower()
|
|
490
|
+
if confirmation != "y":
|
|
491
|
+
return "User denied file write operation."
|
|
492
|
+
|
|
493
|
+
# Create backup before overwriting
|
|
494
|
+
if exists:
|
|
495
|
+
backup_manager.backup(filepath)
|
|
496
|
+
|
|
497
|
+
os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
|
|
498
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
499
|
+
f.write(content)
|
|
500
|
+
return f"✓ Successfully wrote to {filepath}"
|
|
501
|
+
except Exception as e:
|
|
502
|
+
return f"Error writing file: {e}"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def edit_file(filepath: str, old_content: str, new_content: str, auto_approve: bool = False) -> str:
|
|
506
|
+
"""Surgical edit with diff preview and optional auto-approval."""
|
|
507
|
+
logger.debug("edit_file: %s", filepath)
|
|
508
|
+
try:
|
|
509
|
+
_check_git_dirty(filepath)
|
|
510
|
+
if not os.path.exists(filepath):
|
|
511
|
+
match = fuzzy_match_file(filepath)
|
|
512
|
+
if match:
|
|
513
|
+
console.print(f" [dim yellow]⚠️ File '{filepath}' not found, fuzzy matched to '{match}'[/dim yellow]")
|
|
514
|
+
filepath = match
|
|
515
|
+
else:
|
|
516
|
+
return f"Error: File '{filepath}' does not exist. Use write_file to create new files."
|
|
517
|
+
|
|
518
|
+
with open(filepath, encoding="utf-8", errors="ignore") as f:
|
|
519
|
+
file_content = f.read()
|
|
520
|
+
|
|
521
|
+
# Check if old_content exists in the file
|
|
522
|
+
occurrence_count = file_content.count(old_content)
|
|
523
|
+
if occurrence_count == 0:
|
|
524
|
+
# Attempt auto-healing by using a whitespace-agnostic regex
|
|
525
|
+
parts = re.split(r'\s+', old_content.strip())
|
|
526
|
+
escaped_parts = [re.escape(p) for p in parts if p]
|
|
527
|
+
if escaped_parts:
|
|
528
|
+
pattern_str = r'\s+'.join(escaped_parts)
|
|
529
|
+
try:
|
|
530
|
+
matches = list(re.finditer(pattern_str, file_content))
|
|
531
|
+
if len(matches) == 1:
|
|
532
|
+
# Exactly one match found! Auto-heal
|
|
533
|
+
actual_old = matches[0].group(0)
|
|
534
|
+
old_content = actual_old
|
|
535
|
+
console.print(f" [dim yellow]⚡ Auto-healed whitespace mismatch in {os.path.basename(filepath)}[/dim yellow]")
|
|
536
|
+
occurrence_count = 1
|
|
537
|
+
elif len(matches) > 1:
|
|
538
|
+
return (
|
|
539
|
+
f"Error: Exact match not found, and fuzzy match found {len(matches)} occurrences. "
|
|
540
|
+
"Please be more specific."
|
|
541
|
+
)
|
|
542
|
+
except Exception as e:
|
|
543
|
+
logger.debug("Auto-heal regex failed: %s", e)
|
|
544
|
+
|
|
545
|
+
if occurrence_count == 0:
|
|
546
|
+
return (
|
|
547
|
+
f"Error: Could not find the specified text in {filepath}. "
|
|
548
|
+
f"Please read the file first to get the exact content."
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if occurrence_count > 1:
|
|
552
|
+
return (
|
|
553
|
+
f"Error: Found {occurrence_count} occurrences of the target text in {filepath}. "
|
|
554
|
+
f"Please provide a more specific/unique block to match exactly one location."
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Show diff preview
|
|
558
|
+
new_file_content = file_content.replace(old_content, new_content, 1)
|
|
559
|
+
diff = list(
|
|
560
|
+
difflib.unified_diff(
|
|
561
|
+
file_content.splitlines(keepends=True),
|
|
562
|
+
new_file_content.splitlines(keepends=True),
|
|
563
|
+
fromfile=f"a/{filepath}",
|
|
564
|
+
tofile=f"b/{filepath}",
|
|
565
|
+
n=3,
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
if not diff:
|
|
570
|
+
return "No changes detected."
|
|
571
|
+
|
|
572
|
+
console.print(
|
|
573
|
+
Panel(
|
|
574
|
+
f"[bold magenta]Aizen wants to edit:[/bold magenta] [cyan]{filepath}[/cyan]",
|
|
575
|
+
border_style="magenta",
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
_render_diff(diff, filepath)
|
|
579
|
+
|
|
580
|
+
# YOLO mode: skip confirmation
|
|
581
|
+
if not auto_approve:
|
|
582
|
+
with terminal_lock:
|
|
583
|
+
confirmation = input(" Apply edit? (y/n): ").strip().lower()
|
|
584
|
+
if confirmation != "y":
|
|
585
|
+
return "User denied the edit."
|
|
586
|
+
|
|
587
|
+
# Create backup
|
|
588
|
+
backup_manager.backup(filepath)
|
|
589
|
+
|
|
590
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
591
|
+
f.write(new_file_content)
|
|
592
|
+
|
|
593
|
+
return f"✓ Successfully edited {filepath}"
|
|
594
|
+
except Exception as e:
|
|
595
|
+
return f"Error editing file: {e}"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def is_command_safe(command: str) -> bool:
|
|
599
|
+
"""Check if a command is safe to auto-execute without confirmation."""
|
|
600
|
+
cmd_stripped = command.strip()
|
|
601
|
+
|
|
602
|
+
# Check dangerous patterns first
|
|
603
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
604
|
+
if re.search(pattern, cmd_stripped):
|
|
605
|
+
return False
|
|
606
|
+
|
|
607
|
+
# Check safe prefixes
|
|
608
|
+
for safe in SAFE_COMMAND_PREFIXES:
|
|
609
|
+
if cmd_stripped == safe or cmd_stripped.startswith(safe + " "):
|
|
610
|
+
return True
|
|
611
|
+
|
|
612
|
+
return False
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def run_command_impl(command: str, auto_approve: bool = False, timeout: int = 120, background: bool = False) -> str:
|
|
616
|
+
"""Execute a shell command with safety checks, configurable timeout, and live output."""
|
|
617
|
+
logger.debug("run_command: %s (timeout=%ds, background=%s)", command, timeout, background)
|
|
618
|
+
|
|
619
|
+
# Intercept pure 'cd' commands to update process working directory persistently
|
|
620
|
+
cmd_stripped = command.strip()
|
|
621
|
+
if cmd_stripped.startswith("cd ") and not any(sep in cmd_stripped for sep in ["&&", ";", "||", "|"]):
|
|
622
|
+
target_dir = cmd_stripped[3:].strip()
|
|
623
|
+
target_dir = os.path.expanduser(target_dir.strip("\"'"))
|
|
624
|
+
try:
|
|
625
|
+
os.chdir(target_dir)
|
|
626
|
+
new_cwd = os.getcwd()
|
|
627
|
+
logger.info("Changed working directory to %s", new_cwd)
|
|
628
|
+
console.print(f" [dim]▶ Changed directory to {new_cwd}[/dim]")
|
|
629
|
+
return f"Working directory changed to {new_cwd}"
|
|
630
|
+
except Exception as e:
|
|
631
|
+
logger.error("Failed to change directory to '%s': %s", target_dir, e)
|
|
632
|
+
return f"Error changing directory: {e}"
|
|
633
|
+
safe = is_command_safe(command)
|
|
634
|
+
|
|
635
|
+
if not safe and not auto_approve:
|
|
636
|
+
console.print(
|
|
637
|
+
Panel(
|
|
638
|
+
f"[bold magenta]Aizen wants to run:[/bold magenta]\n\n[white]{command}[/white]",
|
|
639
|
+
border_style="magenta",
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
with terminal_lock:
|
|
643
|
+
confirmation = input(" Allow? (y/n): ").strip().lower()
|
|
644
|
+
if confirmation != "y":
|
|
645
|
+
return "User denied command execution."
|
|
646
|
+
elif safe:
|
|
647
|
+
console.print(f" [dim]▶ {command}{' (background)' if background else ''}[/dim]")
|
|
648
|
+
|
|
649
|
+
try:
|
|
650
|
+
# Use Popen for streaming output with live display
|
|
651
|
+
import select
|
|
652
|
+
|
|
653
|
+
proc = subprocess.Popen(
|
|
654
|
+
command,
|
|
655
|
+
shell=True,
|
|
656
|
+
text=True,
|
|
657
|
+
stdout=subprocess.PIPE,
|
|
658
|
+
stderr=subprocess.PIPE,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
if background:
|
|
662
|
+
task_id = f"bg_{uuid.uuid4().hex[:8]}"
|
|
663
|
+
task_info = {
|
|
664
|
+
"process": proc,
|
|
665
|
+
"stdout": [],
|
|
666
|
+
"stderr": [],
|
|
667
|
+
"command": command,
|
|
668
|
+
"start_time": time.time()
|
|
669
|
+
}
|
|
670
|
+
with background_tasks_lock:
|
|
671
|
+
background_tasks[task_id] = task_info
|
|
672
|
+
|
|
673
|
+
def stream_reader(pipe, dest_list):
|
|
674
|
+
for line in iter(pipe.readline, ''):
|
|
675
|
+
dest_list.append(line)
|
|
676
|
+
pipe.close()
|
|
677
|
+
|
|
678
|
+
threading.Thread(target=stream_reader, args=(proc.stdout, task_info["stdout"]), daemon=True).start()
|
|
679
|
+
threading.Thread(target=stream_reader, args=(proc.stderr, task_info["stderr"]), daemon=True).start()
|
|
680
|
+
|
|
681
|
+
return f"Task started in background with ID: {task_id}"
|
|
682
|
+
|
|
683
|
+
stdout_lines: list[str] = []
|
|
684
|
+
stderr_lines: list[str] = []
|
|
685
|
+
start_time = time.time()
|
|
686
|
+
|
|
687
|
+
with Live(
|
|
688
|
+
Text(" ▶ Running...", style="dim italic"),
|
|
689
|
+
console=console,
|
|
690
|
+
refresh_per_second=4,
|
|
691
|
+
transient=True,
|
|
692
|
+
) as live:
|
|
693
|
+
while proc.poll() is None:
|
|
694
|
+
elapsed = time.time() - start_time
|
|
695
|
+
if elapsed > timeout:
|
|
696
|
+
proc.kill()
|
|
697
|
+
logger.warning("Command timed out after %ds: %s", timeout, command)
|
|
698
|
+
return f"Error: Command timed out after {timeout} seconds."
|
|
699
|
+
|
|
700
|
+
# Read available stdout non-blockingly
|
|
701
|
+
if proc.stdout:
|
|
702
|
+
rlist, _, _ = select.select([proc.stdout], [], [], 0.1)
|
|
703
|
+
if rlist:
|
|
704
|
+
line = proc.stdout.readline()
|
|
705
|
+
if line:
|
|
706
|
+
stdout_lines.append(line)
|
|
707
|
+
# Show live output tail (last 15 lines)
|
|
708
|
+
tail = "".join(stdout_lines[-15:])
|
|
709
|
+
display = Text()
|
|
710
|
+
display.append(f" ▶ Running ({elapsed:.0f}s)\n", style="dim italic")
|
|
711
|
+
display.append(tail.rstrip(), style="dim")
|
|
712
|
+
live.update(display)
|
|
713
|
+
|
|
714
|
+
# Read remaining output after process exits
|
|
715
|
+
if proc.stdout:
|
|
716
|
+
remaining = proc.stdout.read()
|
|
717
|
+
if remaining:
|
|
718
|
+
stdout_lines.append(remaining)
|
|
719
|
+
if proc.stderr:
|
|
720
|
+
stderr_lines.append(proc.stderr.read())
|
|
721
|
+
|
|
722
|
+
output = "".join(stdout_lines)
|
|
723
|
+
stderr_output = "".join(stderr_lines).strip()
|
|
724
|
+
|
|
725
|
+
if stderr_output:
|
|
726
|
+
if output:
|
|
727
|
+
output += f"\nSTDERR:\n{stderr_output}"
|
|
728
|
+
else:
|
|
729
|
+
output = stderr_output
|
|
730
|
+
if proc.returncode != 0:
|
|
731
|
+
output += f"\n[Exit code: {proc.returncode}]"
|
|
732
|
+
return output.strip() if output.strip() else f"Command completed (exit code {proc.returncode})"
|
|
733
|
+
except subprocess.TimeoutExpired:
|
|
734
|
+
logger.warning("Command timed out after %ds: %s", timeout, command)
|
|
735
|
+
return f"Error: Command timed out after {timeout} seconds."
|
|
736
|
+
except Exception as e:
|
|
737
|
+
logger.exception("Error executing command: %s", command)
|
|
738
|
+
return f"Error executing command: {e}"
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def check_background_task_impl(task_id: str) -> str:
|
|
742
|
+
"""Checks the status of a background task and returns its recent output."""
|
|
743
|
+
with background_tasks_lock:
|
|
744
|
+
if task_id not in background_tasks:
|
|
745
|
+
return f"Error: No such background task '{task_id}'."
|
|
746
|
+
task = background_tasks[task_id]
|
|
747
|
+
|
|
748
|
+
proc = task["process"]
|
|
749
|
+
|
|
750
|
+
out_lines = list(task["stdout"])
|
|
751
|
+
err_lines = list(task["stderr"])
|
|
752
|
+
|
|
753
|
+
stdout_str = "".join(out_lines[-100:]).strip() # Return last 100 lines to avoid massive output
|
|
754
|
+
stderr_str = "".join(err_lines[-100:]).strip()
|
|
755
|
+
|
|
756
|
+
status = "RUNNING" if proc.poll() is None else f"FINISHED (Exit code {proc.returncode})"
|
|
757
|
+
|
|
758
|
+
result = f"Task: {task_id}\nCommand: {task['command']}\nStatus: {status}\n\n"
|
|
759
|
+
if stdout_str:
|
|
760
|
+
result += f"--- STDOUT (last 100 lines) ---\n{stdout_str}\n\n"
|
|
761
|
+
if stderr_str:
|
|
762
|
+
result += f"--- STDERR (last 100 lines) ---\n{stderr_str}\n"
|
|
763
|
+
|
|
764
|
+
# Cleanup if done
|
|
765
|
+
if proc.poll() is not None:
|
|
766
|
+
with background_tasks_lock:
|
|
767
|
+
background_tasks.pop(task_id, None)
|
|
768
|
+
|
|
769
|
+
return result.strip()
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def kill_background_task_impl(task_id: str) -> str:
|
|
773
|
+
"""Kills a running background task."""
|
|
774
|
+
with background_tasks_lock:
|
|
775
|
+
if task_id not in background_tasks:
|
|
776
|
+
return f"Error: No such background task '{task_id}'."
|
|
777
|
+
task = background_tasks.pop(task_id)
|
|
778
|
+
|
|
779
|
+
proc = task["process"]
|
|
780
|
+
|
|
781
|
+
if proc.poll() is None:
|
|
782
|
+
proc.kill()
|
|
783
|
+
return f"Task {task_id} killed."
|
|
784
|
+
else:
|
|
785
|
+
return f"Task {task_id} was already finished."
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def list_directory(path: str = ".") -> str:
|
|
789
|
+
try:
|
|
790
|
+
if not path:
|
|
791
|
+
path = "."
|
|
792
|
+
if not os.path.exists(path):
|
|
793
|
+
return f"Error: Path '{path}' does not exist."
|
|
794
|
+
if not os.path.isdir(path):
|
|
795
|
+
return f"Error: '{path}' is not a directory."
|
|
796
|
+
|
|
797
|
+
items = os.listdir(path)
|
|
798
|
+
ignore_patterns = load_gitignore_patterns()
|
|
799
|
+
|
|
800
|
+
dirs = []
|
|
801
|
+
files = []
|
|
802
|
+
for item in sorted(items):
|
|
803
|
+
item_path = os.path.join(path, item)
|
|
804
|
+
if should_ignore(item_path, ignore_patterns):
|
|
805
|
+
continue
|
|
806
|
+
if os.path.isdir(item_path):
|
|
807
|
+
dirs.append(f"📁 {item}/")
|
|
808
|
+
else:
|
|
809
|
+
try:
|
|
810
|
+
size = os.path.getsize(item_path)
|
|
811
|
+
if size < 1024:
|
|
812
|
+
size_str = f"{size}B"
|
|
813
|
+
elif size < 1024 * 1024:
|
|
814
|
+
size_str = f"{size / 1024:.1f}KB"
|
|
815
|
+
else:
|
|
816
|
+
size_str = f"{size / 1024 / 1024:.1f}MB"
|
|
817
|
+
files.append(f"📄 {item} ({size_str})")
|
|
818
|
+
except OSError:
|
|
819
|
+
files.append(f"📄 {item}")
|
|
820
|
+
|
|
821
|
+
if not dirs and not files:
|
|
822
|
+
return f"Directory '{path}' is empty or all contents are ignored."
|
|
823
|
+
|
|
824
|
+
result = ""
|
|
825
|
+
if dirs:
|
|
826
|
+
result += "\n".join(dirs)
|
|
827
|
+
if files:
|
|
828
|
+
if result:
|
|
829
|
+
result += "\n"
|
|
830
|
+
result += "\n".join(files)
|
|
831
|
+
return result
|
|
832
|
+
except Exception as e:
|
|
833
|
+
return f"Error listing directory: {e}"
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def grep_search(query: str, path: str = ".", is_regex: bool = False) -> str:
|
|
837
|
+
try:
|
|
838
|
+
if not path:
|
|
839
|
+
path = "."
|
|
840
|
+
if not os.path.exists(path):
|
|
841
|
+
return f"Error: Path '{path}' does not exist."
|
|
842
|
+
|
|
843
|
+
if is_regex:
|
|
844
|
+
try:
|
|
845
|
+
pattern = re.compile(query, re.IGNORECASE)
|
|
846
|
+
except re.error as e:
|
|
847
|
+
return f"Invalid regex pattern: {e}"
|
|
848
|
+
|
|
849
|
+
ignore_patterns = load_gitignore_patterns()
|
|
850
|
+
matches = []
|
|
851
|
+
|
|
852
|
+
for root, dirs, files in os.walk(path):
|
|
853
|
+
dirs[:] = [
|
|
854
|
+
d
|
|
855
|
+
for d in dirs
|
|
856
|
+
if not should_ignore(os.path.join(root, d), ignore_patterns)
|
|
857
|
+
]
|
|
858
|
+
|
|
859
|
+
for file in files:
|
|
860
|
+
file_path = os.path.join(root, file)
|
|
861
|
+
if should_ignore(file_path, ignore_patterns):
|
|
862
|
+
continue
|
|
863
|
+
if _is_binary_file(file_path):
|
|
864
|
+
continue
|
|
865
|
+
try:
|
|
866
|
+
with open(file_path, encoding="utf-8", errors="ignore") as f:
|
|
867
|
+
for line_num, line in enumerate(f, 1):
|
|
868
|
+
matched = False
|
|
869
|
+
if is_regex:
|
|
870
|
+
matched = bool(pattern.search(line))
|
|
871
|
+
else:
|
|
872
|
+
matched = query.lower() in line.lower()
|
|
873
|
+
|
|
874
|
+
if matched:
|
|
875
|
+
matches.append(
|
|
876
|
+
f"{file_path}:{line_num}: {line.strip()}"
|
|
877
|
+
)
|
|
878
|
+
if len(matches) >= 50:
|
|
879
|
+
return (
|
|
880
|
+
"\n".join(matches)
|
|
881
|
+
+ "\n\n(Showing first 50 results)"
|
|
882
|
+
)
|
|
883
|
+
except (UnicodeDecodeError, PermissionError, OSError) as e:
|
|
884
|
+
logger.debug("grep_search skipped %s: %s", file_path, e)
|
|
885
|
+
|
|
886
|
+
if not matches:
|
|
887
|
+
return f"No matches found for '{query}'."
|
|
888
|
+
return "\n".join(matches)
|
|
889
|
+
except Exception as e:
|
|
890
|
+
return f"Error searching: {e}"
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def find_files(pattern: str, path: str = ".") -> str:
|
|
894
|
+
try:
|
|
895
|
+
if not path:
|
|
896
|
+
path = "."
|
|
897
|
+
if not os.path.exists(path):
|
|
898
|
+
return f"Error: Path '{path}' does not exist."
|
|
899
|
+
|
|
900
|
+
ignore_patterns = load_gitignore_patterns()
|
|
901
|
+
matches = []
|
|
902
|
+
|
|
903
|
+
for root, dirs, files in os.walk(path):
|
|
904
|
+
dirs[:] = [
|
|
905
|
+
d
|
|
906
|
+
for d in dirs
|
|
907
|
+
if not should_ignore(os.path.join(root, d), ignore_patterns)
|
|
908
|
+
]
|
|
909
|
+
|
|
910
|
+
for file in files:
|
|
911
|
+
if fnmatch.fnmatch(file, pattern) or fnmatch.fnmatch(
|
|
912
|
+
file.lower(), pattern.lower()
|
|
913
|
+
):
|
|
914
|
+
file_path = os.path.join(root, file)
|
|
915
|
+
if not should_ignore(file_path, ignore_patterns):
|
|
916
|
+
matches.append(file_path)
|
|
917
|
+
if len(matches) >= 100:
|
|
918
|
+
return (
|
|
919
|
+
"\n".join(matches) + "\n\n(Showing first 100 results)"
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
if not matches:
|
|
923
|
+
return f"No files matching '{pattern}' found."
|
|
924
|
+
return "\n".join(matches)
|
|
925
|
+
except Exception as e:
|
|
926
|
+
return f"Error finding files: {e}"
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
# ─── Tool Dispatcher ───────────────────────────────────────────────────────────
|
|
930
|
+
|
|
931
|
+
def execute_tool(tool_call, auto_approve: bool = False) -> str:
|
|
932
|
+
"""
|
|
933
|
+
Dispatch and execute a tool call from the AI model.
|
|
934
|
+
|
|
935
|
+
Handles JSON parsing with repair, auto_approve passthrough,
|
|
936
|
+
and configurable timeouts.
|
|
937
|
+
"""
|
|
938
|
+
func_name = tool_call.function.name
|
|
939
|
+
raw_args = tool_call.function.arguments
|
|
940
|
+
logger.debug("Dispatching tool: %s", func_name)
|
|
941
|
+
|
|
942
|
+
# Parse arguments with repair fallback
|
|
943
|
+
try:
|
|
944
|
+
args = json.loads(raw_args)
|
|
945
|
+
except json.JSONDecodeError:
|
|
946
|
+
# Attempt JSON repair
|
|
947
|
+
args = _try_repair_json(raw_args)
|
|
948
|
+
if args is None:
|
|
949
|
+
console.print(
|
|
950
|
+
f" [yellow]⚠️ Malformed JSON from model for {func_name}[/yellow]"
|
|
951
|
+
)
|
|
952
|
+
return (
|
|
953
|
+
f"Error: Invalid JSON in tool arguments for '{func_name}'. "
|
|
954
|
+
f"Please retry with valid JSON. The arguments received were: "
|
|
955
|
+
f"{raw_args[:200]}{'...' if len(raw_args) > 200 else ''}"
|
|
956
|
+
)
|
|
957
|
+
else:
|
|
958
|
+
console.print(
|
|
959
|
+
f" [dim yellow]⚠️ Repaired malformed JSON for {func_name}[/dim yellow]"
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
tool_label = Text(" ⚙️ ", style="magenta")
|
|
963
|
+
tool_label.append(func_name, style="dim magenta")
|
|
964
|
+
|
|
965
|
+
if func_name == "read_file":
|
|
966
|
+
filepath = str(args.get("filepath", ""))
|
|
967
|
+
tool_label.append(f" → {filepath or '?'}", style="dim")
|
|
968
|
+
console.print(tool_label)
|
|
969
|
+
return truncate_output(read_file(filepath))
|
|
970
|
+
|
|
971
|
+
elif func_name == "write_file":
|
|
972
|
+
filepath = str(args.get("filepath", ""))
|
|
973
|
+
content = str(args.get("content", ""))
|
|
974
|
+
tool_label.append(f" → {filepath or '?'}", style="dim")
|
|
975
|
+
console.print(tool_label)
|
|
976
|
+
return write_file_with_diff(filepath, content, auto_approve=auto_approve)
|
|
977
|
+
|
|
978
|
+
elif func_name == "edit_file":
|
|
979
|
+
filepath = str(args.get("filepath", ""))
|
|
980
|
+
old_content = str(args.get("old_content", ""))
|
|
981
|
+
new_content = str(args.get("new_content", ""))
|
|
982
|
+
tool_label.append(f" → {filepath or '?'}", style="dim")
|
|
983
|
+
console.print(tool_label)
|
|
984
|
+
return edit_file(filepath, old_content, new_content, auto_approve=auto_approve)
|
|
985
|
+
|
|
986
|
+
elif func_name == "run_command":
|
|
987
|
+
command = str(args.get("command", ""))
|
|
988
|
+
timeout = int(args.get("timeout", 120))
|
|
989
|
+
background = bool(args.get("background", False))
|
|
990
|
+
tool_label.append(f" → {command or '?'}", style="dim")
|
|
991
|
+
console.print(tool_label)
|
|
992
|
+
return truncate_output(run_command_impl(command, auto_approve, timeout=timeout, background=background))
|
|
993
|
+
|
|
994
|
+
elif func_name == "check_background_task":
|
|
995
|
+
task_id = str(args.get("task_id", ""))
|
|
996
|
+
tool_label.append(f" → {task_id}", style="dim")
|
|
997
|
+
console.print(tool_label)
|
|
998
|
+
return check_background_task_impl(task_id)
|
|
999
|
+
|
|
1000
|
+
elif func_name == "kill_background_task":
|
|
1001
|
+
task_id = str(args.get("task_id", ""))
|
|
1002
|
+
tool_label.append(f" → {task_id}", style="dim")
|
|
1003
|
+
console.print(tool_label)
|
|
1004
|
+
return kill_background_task_impl(task_id)
|
|
1005
|
+
|
|
1006
|
+
elif func_name == "list_directory":
|
|
1007
|
+
p = str(args.get("path", "."))
|
|
1008
|
+
tool_label.append(f" → {p}", style="dim")
|
|
1009
|
+
console.print(tool_label)
|
|
1010
|
+
return truncate_output(list_directory(p))
|
|
1011
|
+
|
|
1012
|
+
elif func_name == "grep_search":
|
|
1013
|
+
query = str(args.get("query", ""))
|
|
1014
|
+
path = str(args.get("path", "."))
|
|
1015
|
+
is_regex = bool(args.get("is_regex", False))
|
|
1016
|
+
tool_label.append(f" → '{query or '?'}'", style="dim")
|
|
1017
|
+
console.print(tool_label)
|
|
1018
|
+
return truncate_output(grep_search(query, path, is_regex))
|
|
1019
|
+
|
|
1020
|
+
elif func_name == "find_files":
|
|
1021
|
+
pattern = str(args.get("pattern", ""))
|
|
1022
|
+
path = str(args.get("path", "."))
|
|
1023
|
+
tool_label.append(f" → {pattern or '?'}", style="dim")
|
|
1024
|
+
console.print(tool_label)
|
|
1025
|
+
return truncate_output(find_files(pattern, path))
|
|
1026
|
+
|
|
1027
|
+
else:
|
|
1028
|
+
# Check if a plugin handles this tool
|
|
1029
|
+
plugin_result = plugin_manager.execute_tool(tool_call, auto_approve=auto_approve)
|
|
1030
|
+
if plugin_result is not None:
|
|
1031
|
+
return plugin_result
|
|
1032
|
+
|
|
1033
|
+
console.print(tool_label)
|
|
1034
|
+
return f"Unknown tool: {func_name}"
|
|
1035
|
+
|