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/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
+