tunacode-cli 0.0.29__py3-none-any.whl → 0.0.31__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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

tunacode/tools/grep.py CHANGED
@@ -6,6 +6,7 @@ This tool provides sophisticated grep-like functionality with:
6
6
  - Multiple search strategies (literal, regex, fuzzy)
7
7
  - Smart result ranking and deduplication
8
8
  - Context-aware output formatting
9
+ - Timeout handling for overly broad patterns (3 second deadline for first match)
9
10
  """
10
11
 
11
12
  import asyncio
@@ -13,12 +14,13 @@ import fnmatch
13
14
  import os
14
15
  import re
15
16
  import subprocess
17
+ import time
16
18
  from concurrent.futures import ThreadPoolExecutor
17
19
  from dataclasses import dataclass
18
20
  from pathlib import Path
19
- from typing import List, Optional
21
+ from typing import List, Optional, Union
20
22
 
21
- from tunacode.exceptions import ToolExecutionError
23
+ from tunacode.exceptions import TooBroadPatternError, ToolExecutionError
22
24
  from tunacode.tools.base import BaseTool
23
25
 
24
26
 
@@ -48,6 +50,7 @@ class SearchConfig:
48
50
  exclude_patterns: List[str] = None
49
51
  max_file_size: int = 1024 * 1024 # 1MB
50
52
  timeout_seconds: int = 30
53
+ first_match_deadline: float = 3.0 # Timeout for finding first match
51
54
 
52
55
 
53
56
  # Fast-Glob Prefilter Configuration
@@ -146,6 +149,7 @@ class ParallelGrep(BaseTool):
146
149
  max_results: int = 50,
147
150
  context_lines: int = 2,
148
151
  search_type: str = "smart", # smart, ripgrep, python, hybrid
152
+ return_format: str = "string", # "string" (default) or "list" (legacy)
149
153
  ) -> str:
150
154
  """
151
155
  Execute parallel grep search with fast-glob prefiltering and multiple strategies.
@@ -174,6 +178,8 @@ class ParallelGrep(BaseTool):
174
178
  )
175
179
 
176
180
  if not candidates:
181
+ if return_format == "list":
182
+ return []
177
183
  return f"No files found matching pattern: {include_pattern}"
178
184
 
179
185
  # 2️⃣ Smart strategy selection based on candidate count
@@ -203,6 +209,7 @@ class ParallelGrep(BaseTool):
203
209
  )
204
210
 
205
211
  # 4️⃣ Execute chosen strategy with pre-filtered candidates
212
+ # Execute search with pre-filtered candidates
206
213
  if search_type == "ripgrep":
207
214
  results = await self._ripgrep_search_filtered(pattern, candidates, config)
208
215
  elif search_type == "python":
@@ -216,151 +223,37 @@ class ParallelGrep(BaseTool):
216
223
  strategy_info = f"Strategy: {search_type} (was {original_search_type}), Files: {len(candidates)}/{MAX_GLOB}"
217
224
  formatted_results = self._format_results(results, pattern, config)
218
225
 
219
- # Add strategy info to results
220
- if formatted_results.startswith("Found"):
221
- lines = formatted_results.split("\n")
222
- lines[1] = (
223
- f"Strategy: {search_type} | Candidates: {len(candidates)} files | " + lines[1]
224
- )
225
- return "\n".join(lines)
226
+ if return_format == "list":
227
+ # Legacy: return list of file paths with at least one match
228
+ file_set = set()
229
+ for r in results:
230
+ file_set.add(r.file_path)
231
+ return list(file_set)
226
232
  else:
227
- return f"{formatted_results}\n\n{strategy_info}"
233
+ # Add strategy info to results
234
+ if formatted_results.startswith("Found"):
235
+ lines = formatted_results.split("\n")
236
+ lines[1] = (
237
+ f"Strategy: {search_type} | Candidates: {len(candidates)} files | "
238
+ + lines[1]
239
+ )
240
+ return "\n".join(lines)
241
+ else:
242
+ return f"{formatted_results}\n\n{strategy_info}"
228
243
 
244
+ except TooBroadPatternError:
245
+ # Re-raise TooBroadPatternError without wrapping it
246
+ raise
229
247
  except Exception as e:
230
248
  raise ToolExecutionError(f"Grep search failed: {str(e)}")
231
249
 
232
- async def _smart_search(
233
- self, pattern: str, directory: str, config: SearchConfig
234
- ) -> List[SearchResult]:
235
- """Smart search that chooses optimal strategy based on context."""
236
-
237
- # Try ripgrep first (fastest for large codebases)
238
- try:
239
- results = await self._ripgrep_search(pattern, directory, config)
240
- if results:
241
- return results
242
- except Exception:
243
- pass
244
-
245
- # Fallback to Python implementation
246
- return await self._python_search(pattern, directory, config)
247
-
248
- async def _ripgrep_search(
249
- self, pattern: str, directory: str, config: SearchConfig
250
- ) -> List[SearchResult]:
251
- """Use ripgrep for high-performance searching."""
252
-
253
- def run_ripgrep():
254
- cmd = ["rg", "--json"]
255
-
256
- # Add options based on config
257
- if not config.case_sensitive:
258
- cmd.append("--ignore-case")
259
- if config.context_lines > 0:
260
- cmd.extend(["--context", str(config.context_lines)])
261
- if config.max_results:
262
- cmd.extend(["--max-count", str(config.max_results)])
263
-
264
- # Add include/exclude patterns
265
- for pattern_str in config.include_patterns:
266
- if pattern_str != "*":
267
- cmd.extend(["--glob", pattern_str])
268
- for pattern_str in config.exclude_patterns:
269
- cmd.extend(["--glob", f"!{pattern_str}"])
270
-
271
- # Add pattern and directory
272
- cmd.extend([pattern, directory])
273
-
274
- try:
275
- result = subprocess.run(
276
- cmd, capture_output=True, text=True, timeout=config.timeout_seconds
277
- )
278
- return result.stdout if result.returncode == 0 else None
279
- except (subprocess.TimeoutExpired, FileNotFoundError):
280
- return None
281
-
282
- # Run ripgrep in thread pool
283
- output = await asyncio.get_event_loop().run_in_executor(self._executor, run_ripgrep)
284
-
285
- if not output:
286
- return []
287
-
288
- # Parse ripgrep JSON output
289
- return self._parse_ripgrep_output(output)
290
-
291
- async def _python_search(
292
- self, pattern: str, directory: str, config: SearchConfig
293
- ) -> List[SearchResult]:
294
- """Pure Python parallel search implementation."""
295
-
296
- # Find all files to search
297
- files = await self._find_files(directory, config)
298
-
299
- # Prepare search pattern
300
- if config.use_regex:
301
- flags = 0 if config.case_sensitive else re.IGNORECASE
302
- regex_pattern = re.compile(pattern, flags)
303
- else:
304
- regex_pattern = None
305
-
306
- # Create search tasks for parallel execution
307
- search_tasks = []
308
- for file_path in files:
309
- task = self._search_file(file_path, pattern, regex_pattern, config)
310
- search_tasks.append(task)
311
-
312
- # Execute searches in parallel
313
- all_results = await asyncio.gather(*search_tasks, return_exceptions=True)
314
-
315
- # Flatten results and filter out exceptions
316
- results = []
317
- for file_results in all_results:
318
- if isinstance(file_results, list):
319
- results.extend(file_results)
320
-
321
- # Sort by relevance and limit results
322
- results.sort(key=lambda r: r.relevance_score, reverse=True)
323
- return results[: config.max_results]
324
-
325
- async def _hybrid_search(
326
- self, pattern: str, directory: str, config: SearchConfig
327
- ) -> List[SearchResult]:
328
- """Hybrid approach using multiple search methods concurrently."""
329
-
330
- # Run multiple search strategies in parallel
331
- tasks = [
332
- self._ripgrep_search(pattern, directory, config),
333
- self._python_search(pattern, directory, config),
334
- ]
335
-
336
- results_list = await asyncio.gather(*tasks, return_exceptions=True)
337
-
338
- # Merge and deduplicate results
339
- all_results = []
340
- for results in results_list:
341
- if isinstance(results, list):
342
- all_results.extend(results)
343
-
344
- # Deduplicate by file path and line number
345
- seen = set()
346
- unique_results = []
347
- for result in all_results:
348
- key = (result.file_path, result.line_number)
349
- if key not in seen:
350
- seen.add(key)
351
- unique_results.append(result)
352
-
353
- # Sort and limit
354
- unique_results.sort(key=lambda r: r.relevance_score, reverse=True)
355
- return unique_results[: config.max_results]
356
-
357
- # ====== NEW FILTERED SEARCH METHODS ======
250
+ # ====== SEARCH METHODS ======
358
251
 
359
252
  async def _ripgrep_search_filtered(
360
253
  self, pattern: str, candidates: List[Path], config: SearchConfig
361
254
  ) -> List[SearchResult]:
362
255
  """
363
- Run ripgrep on pre-filtered file list.
256
+ Run ripgrep on pre-filtered file list with first match deadline.
364
257
  """
365
258
 
366
259
  def run_ripgrep_filtered():
@@ -379,25 +272,87 @@ class ParallelGrep(BaseTool):
379
272
  cmd.extend(str(f) for f in candidates)
380
273
 
381
274
  try:
382
- result = subprocess.run(
383
- cmd, capture_output=True, text=True, timeout=config.timeout_seconds
275
+ # Start the process
276
+ process = subprocess.Popen(
277
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1
384
278
  )
385
- return result.stdout if result.returncode == 0 else None
279
+
280
+ # Monitor for first match within deadline
281
+ start_time = time.time()
282
+ output_lines = []
283
+ first_match_found = False
284
+
285
+ while True:
286
+ # Check if we exceeded the first match deadline
287
+ if (
288
+ not first_match_found
289
+ and (time.time() - start_time) > config.first_match_deadline
290
+ ):
291
+ process.kill()
292
+ process.wait()
293
+ raise TooBroadPatternError(pattern, config.first_match_deadline)
294
+
295
+ # Check if process is still running
296
+ if process.poll() is not None:
297
+ # Process finished, get any remaining output
298
+ remaining_output, _ = process.communicate()
299
+ if remaining_output:
300
+ output_lines.extend(remaining_output.splitlines())
301
+ break
302
+
303
+ # Try to read a line (non-blocking)
304
+ try:
305
+ # Use a small timeout to avoid blocking indefinitely
306
+ line = process.stdout.readline()
307
+ if line:
308
+ output_lines.append(line.rstrip())
309
+ # Check if this is a match line
310
+ if '"type":"match"' in line:
311
+ first_match_found = True
312
+ except Exception:
313
+ pass
314
+
315
+ # Small sleep to avoid busy waiting
316
+ time.sleep(0.01)
317
+
318
+ # Check exit code
319
+ if process.returncode == 0 or output_lines:
320
+ # Return output even if exit code is non-zero but we have matches
321
+ return "\n".join(output_lines)
322
+ else:
323
+ return None
324
+
325
+ except TooBroadPatternError:
326
+ raise
386
327
  except (subprocess.TimeoutExpired, FileNotFoundError):
387
328
  return None
329
+ except Exception:
330
+ # Make sure to clean up the process
331
+ if "process" in locals():
332
+ try:
333
+ process.kill()
334
+ process.wait()
335
+ except Exception:
336
+ pass
337
+ return None
388
338
 
389
- # Run ripgrep in thread pool
390
- output = await asyncio.get_event_loop().run_in_executor(
391
- self._executor, run_ripgrep_filtered
392
- )
393
-
394
- return self._parse_ripgrep_output(output) if output else []
339
+ # Run ripgrep with monitoring in thread pool
340
+ try:
341
+ output = await asyncio.get_event_loop().run_in_executor(
342
+ self._executor, run_ripgrep_filtered
343
+ )
344
+ if output:
345
+ parsed = self._parse_ripgrep_output(output)
346
+ return parsed
347
+ return []
348
+ except TooBroadPatternError:
349
+ raise
395
350
 
396
351
  async def _python_search_filtered(
397
352
  self, pattern: str, candidates: List[Path], config: SearchConfig
398
353
  ) -> List[SearchResult]:
399
354
  """
400
- Run Python parallel search on pre-filtered candidates.
355
+ Run Python parallel search on pre-filtered candidates with first match deadline.
401
356
  """
402
357
  # Prepare search pattern
403
358
  if config.use_regex:
@@ -406,24 +361,63 @@ class ParallelGrep(BaseTool):
406
361
  else:
407
362
  regex_pattern = None
408
363
 
364
+ # Track search progress
365
+ first_match_event = asyncio.Event()
366
+
367
+ async def search_with_monitoring(file_path: Path):
368
+ """Search a file and signal when first match is found."""
369
+ try:
370
+ file_results = await self._search_file(file_path, pattern, regex_pattern, config)
371
+ if file_results and not first_match_event.is_set():
372
+ first_match_event.set()
373
+ return file_results
374
+ except Exception:
375
+ return []
376
+
409
377
  # Create search tasks for candidates only
410
378
  search_tasks = []
411
379
  for file_path in candidates:
412
- task = self._search_file(file_path, pattern, regex_pattern, config)
380
+ task = search_with_monitoring(file_path)
413
381
  search_tasks.append(task)
414
382
 
415
- # Execute searches in parallel
416
- all_results = await asyncio.gather(*search_tasks, return_exceptions=True)
383
+ # Create a deadline task
384
+ async def check_deadline():
385
+ """Monitor for first match deadline."""
386
+ await asyncio.sleep(config.first_match_deadline)
387
+ if not first_match_event.is_set():
388
+ # Cancel all pending tasks
389
+ for task in search_tasks:
390
+ if not task.done():
391
+ task.cancel()
392
+ raise TooBroadPatternError(pattern, config.first_match_deadline)
417
393
 
418
- # Flatten results and filter out exceptions
419
- results = []
420
- for file_results in all_results:
421
- if isinstance(file_results, list):
422
- results.extend(file_results)
394
+ deadline_task = asyncio.create_task(check_deadline())
395
+
396
+ try:
397
+ # Execute searches in parallel with deadline monitoring
398
+ all_results = await asyncio.gather(*search_tasks, return_exceptions=True)
423
399
 
424
- # Sort by relevance and limit results
425
- results.sort(key=lambda r: r.relevance_score, reverse=True)
426
- return results[: config.max_results]
400
+ # Cancel deadline task if we got results
401
+ deadline_task.cancel()
402
+
403
+ # Flatten results and filter out exceptions
404
+ results = []
405
+ for file_results in all_results:
406
+ if isinstance(file_results, list):
407
+ results.extend(file_results)
408
+
409
+ # Sort by relevance and limit results
410
+ results.sort(key=lambda r: r.relevance_score, reverse=True)
411
+ return results[: config.max_results]
412
+
413
+ except asyncio.CancelledError:
414
+ # Re-raise TooBroadPatternError if that's what caused the cancellation
415
+ if deadline_task.done():
416
+ try:
417
+ await deadline_task
418
+ except TooBroadPatternError:
419
+ raise
420
+ return []
427
421
 
428
422
  async def _hybrid_search_filtered(
429
423
  self, pattern: str, candidates: List[Path], config: SearchConfig
@@ -440,6 +434,14 @@ class ParallelGrep(BaseTool):
440
434
 
441
435
  results_list = await asyncio.gather(*tasks, return_exceptions=True)
442
436
 
437
+ # Check if any task raised TooBroadPatternError
438
+ too_broad_errors = [r for r in results_list if isinstance(r, TooBroadPatternError)]
439
+ if too_broad_errors:
440
+ # If both strategies timed out, raise the error
441
+ valid_results = [r for r in results_list if isinstance(r, list)]
442
+ if not valid_results:
443
+ raise too_broad_errors[0]
444
+
443
445
  # Merge and deduplicate results
444
446
  all_results = []
445
447
  for results in results_list:
@@ -459,42 +461,6 @@ class ParallelGrep(BaseTool):
459
461
  unique_results.sort(key=lambda r: r.relevance_score, reverse=True)
460
462
  return unique_results[: config.max_results]
461
463
 
462
- async def _find_files(self, directory: str, config: SearchConfig) -> List[Path]:
463
- """Find all files matching include/exclude patterns."""
464
-
465
- def find_files_sync():
466
- files = []
467
- dir_path = Path(directory)
468
-
469
- for file_path in dir_path.rglob("*"):
470
- if not file_path.is_file():
471
- continue
472
-
473
- # Check file size
474
- try:
475
- if file_path.stat().st_size > config.max_file_size:
476
- continue
477
- except OSError:
478
- continue
479
-
480
- # Check include patterns
481
- if not any(
482
- fnmatch.fnmatch(str(file_path), pattern) for pattern in config.include_patterns
483
- ):
484
- continue
485
-
486
- # Check exclude patterns
487
- if any(
488
- fnmatch.fnmatch(str(file_path), pattern) for pattern in config.exclude_patterns
489
- ):
490
- continue
491
-
492
- files.append(file_path)
493
-
494
- return files
495
-
496
- return await asyncio.get_event_loop().run_in_executor(self._executor, find_files_sync)
497
-
498
464
  async def _search_file(
499
465
  self,
500
466
  file_path: Path,
@@ -676,6 +642,7 @@ class ParallelGrep(BaseTool):
676
642
  async def grep(
677
643
  pattern: str,
678
644
  directory: str = ".",
645
+ path: Optional[str] = None, # Alias for directory
679
646
  case_sensitive: bool = False,
680
647
  use_regex: bool = False,
681
648
  include_files: Optional[str] = None,
@@ -683,7 +650,8 @@ async def grep(
683
650
  max_results: int = 50,
684
651
  context_lines: int = 2,
685
652
  search_type: str = "smart",
686
- ) -> str:
653
+ return_format: str = "string",
654
+ ) -> Union[str, List[str]]:
687
655
  """
688
656
  Advanced parallel grep search with multiple strategies.
689
657
 
@@ -706,6 +674,10 @@ async def grep(
706
674
  grep("function.*export", "src/", use_regex=True, include_files="*.js,*.ts")
707
675
  grep("import.*pandas", ".", include_files="*.py", search_type="hybrid")
708
676
  """
677
+ # Handle path alias for directory
678
+ if path is not None:
679
+ directory = path
680
+
709
681
  tool = ParallelGrep()
710
682
  return await tool._execute(
711
683
  pattern=pattern,
@@ -717,4 +689,5 @@ async def grep(
717
689
  max_results=max_results,
718
690
  context_lines=context_lines,
719
691
  search_type=search_type,
692
+ return_format=return_format,
720
693
  )
@@ -0,0 +1,190 @@
1
+ """
2
+ Module: tunacode.tools.list_dir
3
+
4
+ Directory listing tool for agent operations in the TunaCode application.
5
+ Provides efficient directory listing without using shell commands.
6
+ """
7
+
8
+ import asyncio
9
+ import os
10
+ from pathlib import Path
11
+ from typing import List, Tuple
12
+
13
+ from tunacode.exceptions import ToolExecutionError
14
+ from tunacode.tools.base import FileBasedTool
15
+ from tunacode.types import FilePath, ToolResult
16
+
17
+
18
+ class ListDirTool(FileBasedTool):
19
+ """Tool for listing directory contents without shell commands."""
20
+
21
+ @property
22
+ def tool_name(self) -> str:
23
+ return "ListDir"
24
+
25
+ async def _execute(
26
+ self, directory: FilePath = ".", max_entries: int = 200, show_hidden: bool = False
27
+ ) -> ToolResult:
28
+ """List the contents of a directory.
29
+
30
+ Args:
31
+ directory: The path to the directory to list (defaults to current directory)
32
+ max_entries: Maximum number of entries to return (default: 200)
33
+ show_hidden: Whether to include hidden files/directories (default: False)
34
+
35
+ Returns:
36
+ ToolResult: Formatted list of files and directories
37
+
38
+ Raises:
39
+ Exception: Directory access errors
40
+ """
41
+ # Convert to Path object for easier handling
42
+ dir_path = Path(directory).resolve()
43
+
44
+ # Verify it's a directory
45
+ if not dir_path.exists():
46
+ raise FileNotFoundError(f"Directory not found: {dir_path}")
47
+
48
+ if not dir_path.is_dir():
49
+ raise NotADirectoryError(f"Not a directory: {dir_path}")
50
+
51
+ # Collect entries in a background thread to prevent blocking the event loop
52
+ def _scan_directory(path: Path) -> List[Tuple[str, bool, str]]:
53
+ """Synchronous helper that scans a directory and returns entry metadata."""
54
+ collected: List[Tuple[str, bool, str]] = []
55
+ try:
56
+ with os.scandir(path) as scanner:
57
+ for entry in scanner:
58
+ # Skip hidden files if requested
59
+ if not show_hidden and entry.name.startswith("."):
60
+ continue
61
+
62
+ try:
63
+ is_directory = entry.is_dir(follow_symlinks=False)
64
+ is_symlink = entry.is_symlink()
65
+
66
+ # Determine type indicator
67
+ if is_symlink:
68
+ type_indicator = "@" # Symlink
69
+ elif is_directory:
70
+ type_indicator = "/" # Directory
71
+ elif entry.is_file():
72
+ # Check if executable
73
+ if os.access(entry.path, os.X_OK):
74
+ type_indicator = "*" # Executable
75
+ else:
76
+ type_indicator = "" # Regular file
77
+ else:
78
+ type_indicator = "?" # Unknown type
79
+
80
+ collected.append((entry.name, is_directory, type_indicator))
81
+
82
+ except (OSError, PermissionError):
83
+ # If we can't stat the entry, include it with unknown type
84
+ collected.append((entry.name, False, "?"))
85
+ except PermissionError:
86
+ # Re-raise for the outer async context to handle uniformly
87
+ raise
88
+
89
+ return collected
90
+
91
+ try:
92
+ entries: List[Tuple[str, bool, str]] = await asyncio.to_thread(
93
+ _scan_directory, dir_path
94
+ )
95
+ except PermissionError as e:
96
+ raise PermissionError(f"Permission denied accessing directory: {dir_path}") from e
97
+
98
+ # Sort entries: directories first, then files, both alphabetically
99
+ entries.sort(key=lambda x: (not x[1], x[0].lower()))
100
+
101
+ # Apply limit after sorting to ensure consistent results
102
+ total_entries = len(entries)
103
+ if len(entries) > max_entries:
104
+ entries = entries[:max_entries]
105
+
106
+ # Format output
107
+ if not entries:
108
+ return f"Directory '{dir_path}' is empty"
109
+
110
+ # Build formatted output
111
+ lines = [f"Contents of '{dir_path}':"]
112
+ lines.append("")
113
+
114
+ # Determine column width for better formatting
115
+ max_name_length = max(len(name) for name, _, _ in entries)
116
+ col_width = min(max_name_length + 2, 50) # Cap at 50 chars
117
+
118
+ for name, is_dir, type_indicator in entries:
119
+ # Truncate long names
120
+ display_name = name
121
+ if len(name) > 47:
122
+ display_name = name[:44] + "..."
123
+
124
+ # Add type indicator
125
+ display_name += type_indicator
126
+
127
+ # Add entry type description
128
+ if is_dir:
129
+ entry_type = "[DIR]"
130
+ else:
131
+ entry_type = "[FILE]"
132
+
133
+ lines.append(f" {display_name:<{col_width}} {entry_type}")
134
+
135
+ # Add summary
136
+ displayed_count = len(entries)
137
+ dir_count = sum(1 for _, is_dir, _ in entries if is_dir)
138
+ file_count = displayed_count - dir_count
139
+
140
+ lines.append("")
141
+ lines.append(
142
+ f"Total: {displayed_count} entries ({dir_count} directories, {file_count} files)"
143
+ )
144
+
145
+ if total_entries > max_entries:
146
+ lines.append(f"Note: Output limited to {max_entries} entries")
147
+
148
+ return "\n".join(lines)
149
+
150
+ def _format_args(self, directory: FilePath = ".", *args, **kwargs) -> str:
151
+ """Format arguments for display."""
152
+ all_args = [repr(str(directory))]
153
+
154
+ # Add other keyword arguments if present
155
+ for key, value in kwargs.items():
156
+ if key not in ["max_entries", "show_hidden"]:
157
+ continue
158
+ all_args.append(f"{key}={repr(value)}")
159
+
160
+ return ", ".join(all_args)
161
+
162
+ def _get_error_context(self, directory: FilePath = None, *args, **kwargs) -> str:
163
+ """Get error context including directory path."""
164
+ if directory:
165
+ return f"listing directory '{directory}'"
166
+ return super()._get_error_context(*args, **kwargs)
167
+
168
+
169
+ # Create the function that maintains compatibility with pydantic-ai
170
+ async def list_dir(directory: str = ".", max_entries: int = 200, show_hidden: bool = False) -> str:
171
+ """
172
+ List the contents of a directory without using shell commands.
173
+
174
+ Uses os.scandir for efficient directory listing with proper error handling.
175
+ Results are sorted with directories first, then files, both alphabetically.
176
+
177
+ Args:
178
+ directory: The path to the directory to list (defaults to current directory)
179
+ max_entries: Maximum number of entries to return (default: 200)
180
+ show_hidden: Whether to include hidden files/directories (default: False)
181
+
182
+ Returns:
183
+ str: Formatted list of directory contents or error message
184
+ """
185
+ tool = ListDirTool(None) # No UI for pydantic-ai compatibility
186
+ try:
187
+ return await tool.execute(directory, max_entries=max_entries, show_hidden=show_hidden)
188
+ except ToolExecutionError as e:
189
+ # Return error message for pydantic-ai compatibility
190
+ return str(e)