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

Files changed (45) hide show
  1. tunacode/cli/commands/implementations/plan.py +8 -8
  2. tunacode/cli/commands/registry.py +2 -2
  3. tunacode/cli/repl.py +214 -407
  4. tunacode/cli/repl_components/command_parser.py +37 -4
  5. tunacode/cli/repl_components/error_recovery.py +79 -1
  6. tunacode/cli/repl_components/output_display.py +14 -11
  7. tunacode/cli/repl_components/tool_executor.py +7 -4
  8. tunacode/configuration/defaults.py +8 -0
  9. tunacode/constants.py +8 -2
  10. tunacode/core/agents/agent_components/agent_config.py +128 -65
  11. tunacode/core/agents/agent_components/node_processor.py +6 -2
  12. tunacode/core/code_index.py +83 -29
  13. tunacode/core/state.py +1 -1
  14. tunacode/core/token_usage/usage_tracker.py +2 -2
  15. tunacode/core/tool_handler.py +3 -3
  16. tunacode/prompts/system.md +117 -490
  17. tunacode/services/mcp.py +29 -7
  18. tunacode/tools/base.py +110 -0
  19. tunacode/tools/bash.py +96 -1
  20. tunacode/tools/exit_plan_mode.py +114 -32
  21. tunacode/tools/glob.py +366 -33
  22. tunacode/tools/grep.py +226 -77
  23. tunacode/tools/grep_components/result_formatter.py +98 -4
  24. tunacode/tools/list_dir.py +132 -2
  25. tunacode/tools/present_plan.py +111 -31
  26. tunacode/tools/read_file.py +91 -0
  27. tunacode/tools/run_command.py +99 -0
  28. tunacode/tools/schema_assembler.py +167 -0
  29. tunacode/tools/todo.py +108 -1
  30. tunacode/tools/update_file.py +94 -0
  31. tunacode/tools/write_file.py +86 -0
  32. tunacode/types.py +10 -9
  33. tunacode/ui/input.py +1 -0
  34. tunacode/ui/keybindings.py +1 -0
  35. tunacode/ui/panels.py +49 -27
  36. tunacode/ui/prompt_manager.py +13 -7
  37. tunacode/utils/json_utils.py +206 -0
  38. tunacode/utils/ripgrep.py +332 -9
  39. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/METADATA +7 -2
  40. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/RECORD +44 -43
  41. tunacode/tools/read_file_async_poc.py +0 -196
  42. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/WHEEL +0 -0
  43. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/entry_points.txt +0 -0
  44. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/licenses/LICENSE +0 -0
  45. {tunacode_cli-0.0.56.dist-info → tunacode_cli-0.0.60.dist-info}/top_level.txt +0 -0
tunacode/tools/glob.py CHANGED
@@ -8,9 +8,15 @@ complementing the grep tool's content search with fast filename-based searching.
8
8
  import asyncio
9
9
  import fnmatch
10
10
  import os
11
+ import re
12
+ from enum import Enum
13
+ from functools import lru_cache
11
14
  from pathlib import Path
12
- from typing import List, Optional
15
+ from typing import Any, Dict, List, Optional, Set, Union
13
16
 
17
+ import defusedxml.ElementTree as ET
18
+
19
+ from tunacode.core.code_index import CodeIndex
14
20
  from tunacode.exceptions import ToolExecutionError
15
21
  from tunacode.tools.base import BaseTool
16
22
 
@@ -35,13 +41,160 @@ EXCLUDE_DIRS = {
35
41
  }
36
42
 
37
43
 
44
+ class SortOrder(Enum):
45
+ """Sorting options for glob results."""
46
+
47
+ MODIFIED = "modified" # Sort by modification time (newest first)
48
+ SIZE = "size" # Sort by file size (largest first)
49
+ ALPHABETICAL = "alphabetical" # Sort alphabetically
50
+ DEPTH = "depth" # Sort by path depth (shallow first)
51
+
52
+
38
53
  class GlobTool(BaseTool):
39
54
  """Fast file pattern matching tool using glob patterns."""
40
55
 
56
+ def __init__(self):
57
+ """Initialize the glob tool."""
58
+ super().__init__()
59
+ self._code_index: Optional[CodeIndex] = None
60
+ self._gitignore_patterns: Optional[Set[str]] = None
61
+
41
62
  @property
42
63
  def tool_name(self) -> str:
43
64
  return "glob"
44
65
 
66
+ @lru_cache(maxsize=1)
67
+ def _get_base_prompt(self) -> str:
68
+ """Load and return the base prompt from XML file.
69
+
70
+ Returns:
71
+ str: The loaded prompt from XML or a default prompt
72
+ """
73
+ try:
74
+ # Load prompt from XML file
75
+ prompt_file = Path(__file__).parent / "prompts" / "glob_prompt.xml"
76
+ if prompt_file.exists():
77
+ tree = ET.parse(prompt_file)
78
+ root = tree.getroot()
79
+ description = root.find("description")
80
+ if description is not None:
81
+ return description.text.strip()
82
+ except Exception:
83
+ pass # Fall through to default
84
+
85
+ # Fallback to default prompt
86
+ return """Fast file pattern matching tool
87
+
88
+ - Supports glob patterns like "**/*.js" or "src/**/*.ts"
89
+ - Returns matching file paths sorted by modification time
90
+ - Use this tool when you need to find files by name patterns"""
91
+
92
+ @lru_cache(maxsize=1)
93
+ def _get_parameters_schema(self) -> Dict[str, Any]:
94
+ """Get the parameters schema for the glob tool."""
95
+ # Try to load from XML first
96
+ try:
97
+ prompt_file = Path(__file__).parent / "prompts" / "glob_prompt.xml"
98
+ if prompt_file.exists():
99
+ tree = ET.parse(prompt_file)
100
+ root = tree.getroot()
101
+ parameters = root.find("parameters")
102
+ if parameters is not None:
103
+ schema: Dict[str, Any] = {"type": "object", "properties": {}, "required": []}
104
+ required_fields: List[str] = []
105
+
106
+ for param in parameters.findall("parameter"):
107
+ name = param.get("name")
108
+ required = param.get("required", "false").lower() == "true"
109
+ param_type = param.find("type")
110
+ description = param.find("description")
111
+
112
+ if name and param_type is not None:
113
+ prop = {
114
+ "type": param_type.text.strip(),
115
+ "description": description.text.strip()
116
+ if description is not None
117
+ else "",
118
+ }
119
+
120
+ schema["properties"][name] = prop
121
+ if required:
122
+ required_fields.append(name)
123
+
124
+ schema["required"] = required_fields
125
+ return schema
126
+ except Exception:
127
+ pass # Fall through to hardcoded schema
128
+
129
+ # Fallback to hardcoded schema
130
+ return {
131
+ "type": "object",
132
+ "properties": {
133
+ "pattern": {
134
+ "type": "string",
135
+ "description": "Glob pattern to match (e.g., '*.py', '**/*.{js,ts}')",
136
+ },
137
+ "directory": {
138
+ "type": "string",
139
+ "description": "Directory to search in",
140
+ "default": ".",
141
+ },
142
+ "recursive": {
143
+ "type": "boolean",
144
+ "description": "Whether to search recursively",
145
+ "default": True,
146
+ },
147
+ "include_hidden": {
148
+ "type": "boolean",
149
+ "description": "Whether to include hidden files/directories",
150
+ "default": False,
151
+ },
152
+ "exclude_dirs": {
153
+ "type": "array",
154
+ "items": {"type": "string"},
155
+ "description": "Additional directories to exclude from search",
156
+ },
157
+ "max_results": {
158
+ "type": "integer",
159
+ "description": "Maximum number of results to return",
160
+ "default": 5000,
161
+ },
162
+ "sort_by": {
163
+ "type": "string",
164
+ "enum": ["modified", "size", "alphabetical", "depth"],
165
+ "description": "How to sort results",
166
+ "default": "modified",
167
+ },
168
+ "case_sensitive": {
169
+ "type": "boolean",
170
+ "description": "Whether pattern matching is case-sensitive",
171
+ "default": False,
172
+ },
173
+ "use_gitignore": {
174
+ "type": "boolean",
175
+ "description": "Whether to respect .gitignore patterns",
176
+ "default": True,
177
+ },
178
+ },
179
+ "required": ["pattern"],
180
+ }
181
+
182
+ def _get_code_index(self, directory: str) -> Optional[CodeIndex]:
183
+ """Get the CodeIndex instance if available and appropriate."""
184
+ # Only use CodeIndex if we're searching from the project root
185
+ if directory != "." and directory != os.getcwd():
186
+ return None
187
+
188
+ if self._code_index is None:
189
+ try:
190
+ self._code_index = CodeIndex.get_instance()
191
+ # Ensure index is built
192
+ self._code_index.build_index()
193
+ except Exception:
194
+ # CodeIndex not available, fall back to filesystem traversal
195
+ self._code_index = None
196
+ return self._code_index
197
+
45
198
  async def _execute(
46
199
  self,
47
200
  pattern: str,
@@ -50,6 +203,9 @@ class GlobTool(BaseTool):
50
203
  include_hidden: bool = False,
51
204
  exclude_dirs: Optional[List[str]] = None,
52
205
  max_results: int = MAX_RESULTS,
206
+ sort_by: Union[str, SortOrder] = SortOrder.MODIFIED,
207
+ case_sensitive: bool = False,
208
+ use_gitignore: bool = True,
53
209
  ) -> str:
54
210
  """
55
211
  Find files matching glob patterns.
@@ -61,6 +217,9 @@ class GlobTool(BaseTool):
61
217
  include_hidden: Whether to include hidden files/directories (default: False)
62
218
  exclude_dirs: Additional directories to exclude from search
63
219
  max_results: Maximum number of results to return (default: 5000)
220
+ sort_by: How to sort results (modified/size/alphabetical/depth)
221
+ case_sensitive: Whether pattern matching is case-sensitive (default: False)
222
+ use_gitignore: Whether to respect .gitignore patterns (default: True)
64
223
 
65
224
  Returns:
66
225
  List of matching file paths as a formatted string
@@ -85,20 +244,45 @@ class GlobTool(BaseTool):
85
244
  if exclude_dirs:
86
245
  all_exclude_dirs.update(exclude_dirs)
87
246
 
247
+ # Convert sort_by to enum if string
248
+ if isinstance(sort_by, str):
249
+ try:
250
+ sort_by = SortOrder(sort_by)
251
+ except ValueError:
252
+ sort_by = SortOrder.MODIFIED
253
+
88
254
  # Handle multiple extensions pattern like "*.{py,js,ts}"
89
255
  patterns = self._expand_brace_pattern(pattern)
90
256
 
91
- # Perform the glob search
92
- matches = await self._glob_search(
93
- root_path, patterns, recursive, include_hidden, all_exclude_dirs, max_results
94
- )
257
+ # Load gitignore patterns if requested
258
+ if use_gitignore:
259
+ await self._load_gitignore_patterns(root_path)
260
+
261
+ # Try to use CodeIndex for faster lookup if available
262
+ code_index = self._get_code_index(directory)
263
+ if code_index and not include_hidden and recursive:
264
+ # Use CodeIndex for common cases
265
+ matches = await self._glob_search_with_index(
266
+ code_index, patterns, root_path, all_exclude_dirs, max_results, case_sensitive
267
+ )
268
+ else:
269
+ # Fall back to filesystem traversal
270
+ matches = await self._glob_search(
271
+ root_path,
272
+ patterns,
273
+ recursive,
274
+ include_hidden,
275
+ all_exclude_dirs,
276
+ max_results,
277
+ case_sensitive,
278
+ )
95
279
 
96
280
  # Format results
97
281
  if not matches:
98
282
  return f"No files found matching pattern: {pattern}"
99
283
 
100
- # Sort matches by path
101
- matches.sort()
284
+ # Sort matches based on sort_by parameter
285
+ matches = await self._sort_matches(matches, sort_by)
102
286
 
103
287
  # Create formatted output
104
288
  output = []
@@ -128,6 +312,7 @@ class GlobTool(BaseTool):
128
312
  def _expand_brace_pattern(self, pattern: str) -> List[str]:
129
313
  """
130
314
  Expand brace patterns like "*.{py,js,ts}" into multiple patterns.
315
+ Also supports extended patterns like "?(pattern)" for optional matching.
131
316
 
132
317
  Args:
133
318
  pattern: Pattern that may contain braces
@@ -138,24 +323,129 @@ class GlobTool(BaseTool):
138
323
  if "{" not in pattern or "}" not in pattern:
139
324
  return [pattern]
140
325
 
141
- # Find the brace expression
142
- start = pattern.find("{")
143
- end = pattern.find("}")
144
-
145
- if start == -1 or end == -1 or end < start:
146
- return [pattern]
147
-
148
- # Extract parts
149
- prefix = pattern[:start]
150
- suffix = pattern[end + 1 :]
151
- options = pattern[start + 1 : end].split(",")
152
-
153
- # Generate all combinations
154
- patterns = []
155
- for option in options:
156
- patterns.append(prefix + option.strip() + suffix)
157
-
158
- return patterns
326
+ # Handle nested braces recursively
327
+ expanded = []
328
+ stack = [pattern]
329
+
330
+ while stack:
331
+ current = stack.pop()
332
+
333
+ # Find the innermost brace expression
334
+ start = -1
335
+ depth = 0
336
+ for i, char in enumerate(current):
337
+ if char == "{":
338
+ if depth == 0:
339
+ start = i
340
+ depth += 1
341
+ elif char == "}":
342
+ depth -= 1
343
+ if depth == 0 and start != -1:
344
+ # Found a complete brace expression
345
+ prefix = current[:start]
346
+ suffix = current[i + 1 :]
347
+ options = current[start + 1 : i].split(",")
348
+
349
+ # Generate all combinations
350
+ for option in options:
351
+ new_pattern = prefix + option.strip() + suffix
352
+ if "{" in new_pattern:
353
+ stack.append(new_pattern)
354
+ else:
355
+ expanded.append(new_pattern)
356
+ break
357
+ else:
358
+ # No more braces to expand
359
+ expanded.append(current)
360
+
361
+ return expanded
362
+
363
+ async def _load_gitignore_patterns(self, root: Path) -> None:
364
+ """Load .gitignore patterns from the repository."""
365
+ if self._gitignore_patterns is not None:
366
+ return
367
+
368
+ self._gitignore_patterns = set()
369
+
370
+ # Look for .gitignore, .ignore, and .rgignore files
371
+ ignore_files = [".gitignore", ".ignore", ".rgignore"]
372
+
373
+ for ignore_file in ignore_files:
374
+ ignore_path = root / ignore_file
375
+ if ignore_path.exists():
376
+ try:
377
+ with open(ignore_path, "r", encoding="utf-8") as f:
378
+ for line in f:
379
+ line = line.strip()
380
+ if line and not line.startswith("#"):
381
+ self._gitignore_patterns.add(line)
382
+ except Exception:
383
+ pass
384
+
385
+ async def _glob_search_with_index(
386
+ self,
387
+ code_index: CodeIndex,
388
+ patterns: List[str],
389
+ root: Path,
390
+ exclude_dirs: set,
391
+ max_results: int,
392
+ case_sensitive: bool,
393
+ ) -> List[str]:
394
+ """Use CodeIndex for faster file matching."""
395
+ # Get all files from index
396
+ all_files = code_index.get_all_files()
397
+
398
+ matches = []
399
+ for file_path in all_files:
400
+ # Convert to absolute path
401
+ abs_path = code_index.root_dir / file_path
402
+
403
+ # Check against patterns
404
+ for pattern in patterns:
405
+ if self._match_pattern(str(file_path), pattern, case_sensitive):
406
+ # Check if in excluded directories
407
+ skip = False
408
+ for exclude_dir in exclude_dirs:
409
+ if exclude_dir in file_path.parts:
410
+ skip = True
411
+ break
412
+
413
+ if not skip:
414
+ matches.append(str(abs_path))
415
+ if len(matches) >= max_results:
416
+ return matches
417
+ break
418
+
419
+ return matches
420
+
421
+ def _match_pattern(self, path: str, pattern: str, case_sensitive: bool) -> bool:
422
+ """Match a path against a glob pattern."""
423
+ # Handle ** for recursive matching
424
+ if "**" in pattern:
425
+ # Special case: **/*.ext should match both root files and nested files
426
+ if pattern.startswith("**/"):
427
+ # Match the pattern after **/ directly and also with any prefix
428
+ suffix_pattern = pattern[3:] # Remove **/
429
+ if case_sensitive:
430
+ # Check if path matches the suffix directly (root files)
431
+ if fnmatch.fnmatch(path, suffix_pattern):
432
+ return True
433
+ else:
434
+ if fnmatch.fnmatch(path.lower(), suffix_pattern.lower()):
435
+ return True
436
+
437
+ # Full recursive pattern matching
438
+ regex_pat = pattern.replace("**", "__STARSTAR__")
439
+ regex_pat = fnmatch.translate(regex_pat)
440
+ regex_pat = regex_pat.replace("__STARSTAR__", ".*")
441
+ flags = 0 if case_sensitive else re.IGNORECASE
442
+ return bool(re.match(regex_pat, path, flags))
443
+ else:
444
+ # Simple pattern matching
445
+ if case_sensitive:
446
+ return fnmatch.fnmatch(path, pattern)
447
+ else:
448
+ return fnmatch.fnmatch(path.lower(), pattern.lower())
159
449
 
160
450
  async def _glob_search(
161
451
  self,
@@ -165,6 +455,7 @@ class GlobTool(BaseTool):
165
455
  include_hidden: bool,
166
456
  exclude_dirs: set,
167
457
  max_results: int,
458
+ case_sensitive: bool = False,
168
459
  ) -> List[str]:
169
460
  """
170
461
  Perform the actual glob search using os.scandir for speed.
@@ -182,14 +473,13 @@ class GlobTool(BaseTool):
182
473
  """
183
474
 
184
475
  def search_sync():
185
- # Import re here to avoid issues at module level
186
- import re
187
-
188
476
  matches = []
189
477
  stack = [root]
190
478
 
191
479
  # Compile patterns to regex for faster matching
192
480
  compiled_patterns = []
481
+ flags = 0 if case_sensitive else re.IGNORECASE
482
+
193
483
  for pat in patterns:
194
484
  # Handle ** for recursive matching
195
485
  if "**" in pat:
@@ -197,11 +487,9 @@ class GlobTool(BaseTool):
197
487
  regex_pat = pat.replace("**", "__STARSTAR__")
198
488
  regex_pat = fnmatch.translate(regex_pat)
199
489
  regex_pat = regex_pat.replace("__STARSTAR__", ".*")
200
- compiled_patterns.append((pat, re.compile(regex_pat, re.IGNORECASE)))
490
+ compiled_patterns.append((pat, re.compile(regex_pat, flags)))
201
491
  else:
202
- compiled_patterns.append(
203
- (pat, re.compile(fnmatch.translate(pat), re.IGNORECASE))
204
- )
492
+ compiled_patterns.append((pat, re.compile(fnmatch.translate(pat), flags)))
205
493
 
206
494
  while stack and len(matches) < max_results:
207
495
  current_dir = stack.pop()
@@ -224,9 +512,22 @@ class GlobTool(BaseTool):
224
512
  for original_pat, compiled_pat in compiled_patterns:
225
513
  # For ** patterns, match against full relative path
226
514
  if "**" in original_pat:
227
- if compiled_pat.match(rel_path):
515
+ # Special handling for **/*.ext patterns
516
+ if original_pat.startswith("**/") and not recursive:
517
+ # In non-recursive mode, match only filename
518
+ suffix_pat = original_pat[3:]
519
+ if fnmatch.fnmatch(entry.name, suffix_pat):
520
+ matches.append(entry.path)
521
+ break
522
+ elif compiled_pat.match(rel_path):
228
523
  matches.append(entry.path)
229
524
  break
525
+ # Also check if filename matches the pattern after **/
526
+ elif original_pat.startswith("**/"):
527
+ suffix_pat = original_pat[3:]
528
+ if fnmatch.fnmatch(entry.name, suffix_pat):
529
+ matches.append(entry.path)
530
+ break
230
531
  else:
231
532
  # For simple patterns, match against filename only
232
533
  if compiled_pat.match(entry.name):
@@ -246,6 +547,28 @@ class GlobTool(BaseTool):
246
547
  loop = asyncio.get_event_loop()
247
548
  return await loop.run_in_executor(None, search_sync)
248
549
 
550
+ async def _sort_matches(self, matches: List[str], sort_by: SortOrder) -> List[str]:
551
+ """Sort matches based on the specified order."""
552
+ if not matches:
553
+ return matches
554
+
555
+ def sort_sync():
556
+ if sort_by == SortOrder.MODIFIED:
557
+ # Sort by modification time (newest first)
558
+ return sorted(matches, key=lambda p: os.path.getmtime(p), reverse=True)
559
+ elif sort_by == SortOrder.SIZE:
560
+ # Sort by file size (largest first)
561
+ return sorted(matches, key=lambda p: os.path.getsize(p), reverse=True)
562
+ elif sort_by == SortOrder.DEPTH:
563
+ # Sort by path depth (shallow first), then alphabetically
564
+ return sorted(matches, key=lambda p: (p.count(os.sep), p))
565
+ else: # SortOrder.ALPHABETICAL
566
+ # Sort alphabetically
567
+ return sorted(matches)
568
+
569
+ loop = asyncio.get_event_loop()
570
+ return await loop.run_in_executor(None, sort_sync)
571
+
249
572
 
250
573
  # Create the tool function for pydantic-ai
251
574
  async def glob(
@@ -255,6 +578,9 @@ async def glob(
255
578
  include_hidden: bool = False,
256
579
  exclude_dirs: Optional[List[str]] = None,
257
580
  max_results: int = MAX_RESULTS,
581
+ sort_by: str = "modified",
582
+ case_sensitive: bool = False,
583
+ use_gitignore: bool = True,
258
584
  ) -> str:
259
585
  """
260
586
  Find files matching glob patterns with fast filesystem traversal.
@@ -266,6 +592,9 @@ async def glob(
266
592
  include_hidden: Whether to include hidden files/directories (default: False)
267
593
  exclude_dirs: Additional directories to exclude from search (default: common build/cache dirs)
268
594
  max_results: Maximum number of results to return (default: 5000)
595
+ sort_by: How to sort results - "modified", "size", "alphabetical", or "depth" (default: "modified")
596
+ case_sensitive: Whether pattern matching is case-sensitive (default: False)
597
+ use_gitignore: Whether to respect .gitignore patterns (default: True)
269
598
 
270
599
  Returns:
271
600
  Formatted list of matching file paths grouped by directory
@@ -276,6 +605,7 @@ async def glob(
276
605
  glob("*.{js,ts,jsx,tsx}") # Multiple extensions
277
606
  glob("src/**/test_*.py") # Test files in src directory
278
607
  glob("**/*.md", include_hidden=True) # Include hidden directories
608
+ glob("*.py", sort_by="size") # Sort by file size
279
609
  """
280
610
  tool = GlobTool()
281
611
  return await tool._execute(
@@ -285,4 +615,7 @@ async def glob(
285
615
  include_hidden=include_hidden,
286
616
  exclude_dirs=exclude_dirs,
287
617
  max_results=max_results,
618
+ sort_by=sort_by,
619
+ case_sensitive=case_sensitive,
620
+ use_gitignore=use_gitignore,
288
621
  )