tunacode-cli 0.0.30__py3-none-any.whl → 0.0.32__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.
- api/auth.py +13 -0
- api/users.py +8 -0
- tunacode/cli/commands.py +113 -232
- tunacode/cli/repl.py +40 -84
- tunacode/constants.py +10 -1
- tunacode/core/agents/__init__.py +0 -4
- tunacode/core/agents/main.py +345 -43
- tunacode/core/code_index.py +479 -0
- tunacode/core/setup/git_safety_setup.py +7 -9
- tunacode/core/tool_handler.py +18 -0
- tunacode/exceptions.py +13 -0
- tunacode/prompts/system.md +237 -28
- tunacode/tools/glob.py +288 -0
- tunacode/tools/grep.py +168 -195
- tunacode/tools/list_dir.py +190 -0
- tunacode/tools/read_file.py +9 -3
- tunacode/tools/read_file_async_poc.py +188 -0
- {tunacode_cli-0.0.30.dist-info → tunacode_cli-0.0.32.dist-info}/METADATA +16 -7
- {tunacode_cli-0.0.30.dist-info → tunacode_cli-0.0.32.dist-info}/RECORD +23 -21
- {tunacode_cli-0.0.30.dist-info → tunacode_cli-0.0.32.dist-info}/top_level.txt +1 -0
- tunacode/core/agents/orchestrator.py +0 -213
- tunacode/core/agents/planner_schema.py +0 -9
- tunacode/core/agents/readonly.py +0 -65
- tunacode/core/llm/planner.py +0 -62
- {tunacode_cli-0.0.30.dist-info → tunacode_cli-0.0.32.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.30.dist-info → tunacode_cli-0.0.32.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.30.dist-info → tunacode_cli-0.0.32.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
275
|
+
# Start the process
|
|
276
|
+
process = subprocess.Popen(
|
|
277
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1
|
|
384
278
|
)
|
|
385
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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 =
|
|
380
|
+
task = search_with_monitoring(file_path)
|
|
413
381
|
search_tasks.append(task)
|
|
414
382
|
|
|
415
|
-
#
|
|
416
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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)
|