@@ -16,6 +16,49 @@ from .fs_safety import ( |
| 16 | 16 | resolve_workspace_path, |
| 17 | 17 | ) |
| 18 | 18 | |
| 19 | +_GLOB_MAGIC_CHARS = "*?[" |
| 20 | + |
| 21 | + |
| 22 | +def _has_glob_magic(segment: str) -> bool: |
| 23 | + """Return whether one path segment contains glob syntax.""" |
| 24 | + |
| 25 | + return any(char in segment for char in _GLOB_MAGIC_CHARS) |
| 26 | + |
| 27 | + |
| 28 | +def _resolve_glob_base_and_pattern( |
| 29 | + pattern: str, |
| 30 | + path: str, |
| 31 | +) -> tuple[Path, str]: |
| 32 | + """Resolve glob inputs, including `~`/absolute patterns outside the cwd.""" |
| 33 | + |
| 34 | + expanded_pattern = Path(pattern).expanduser() |
| 35 | + pattern_is_explicit_path = pattern.startswith("~") or expanded_pattern.is_absolute() |
| 36 | + |
| 37 | + if not pattern_is_explicit_path: |
| 38 | + base_path = resolve_workspace_path(path, workspace_root=None) |
| 39 | + return base_path, pattern |
| 40 | + |
| 41 | + base_parts: list[str] = [] |
| 42 | + pattern_parts: list[str] = [] |
| 43 | + saw_glob = False |
| 44 | + for part in expanded_pattern.parts: |
| 45 | + if saw_glob or _has_glob_magic(part): |
| 46 | + saw_glob = True |
| 47 | + pattern_parts.append(part) |
| 48 | + else: |
| 49 | + base_parts.append(part) |
| 50 | + |
| 51 | + if not pattern_parts: |
| 52 | + if expanded_pattern.name: |
| 53 | + pattern_parts = [expanded_pattern.name] |
| 54 | + base_parts = list(expanded_pattern.parent.parts) |
| 55 | + else: |
| 56 | + pattern_parts = ["*"] |
| 57 | + |
| 58 | + raw_base = str(Path(*base_parts)) if base_parts else expanded_pattern.anchor or "." |
| 59 | + base_path = resolve_workspace_path(raw_base, workspace_root=None) |
| 60 | + return base_path, "/".join(pattern_parts) |
| 61 | + |
| 19 | 62 | |
| 20 | 63 | class ReadTool(Tool): |
| 21 | 64 | """Read file contents.""" |
@@ -544,7 +587,11 @@ class GlobTool(Tool): |
| 544 | 587 | |
| 545 | 588 | @property |
| 546 | 589 | def description(self) -> str: |
| 547 | | - return "Find files matching a glob pattern (e.g., '**/*.py', 'src/*.ts')." |
| 590 | + return ( |
| 591 | + "Find files matching a glob pattern (e.g., '**/*.py', 'src/*.ts'). " |
| 592 | + "For external directories, prefer path='~/Loader/animals' with " |
| 593 | + "pattern='*.html'; absolute or '~'-prefixed patterns are also accepted." |
| 594 | + ) |
| 548 | 595 | |
| 549 | 596 | @property |
| 550 | 597 | def parameters(self) -> dict[str, Any]: |
@@ -572,20 +619,18 @@ class GlobTool(Tool): |
| 572 | 619 | ) -> ToolResult: |
| 573 | 620 | try: |
| 574 | 621 | # Glob is read-only — don't enforce workspace boundary |
| 575 | | - base_path = resolve_workspace_path( |
| 576 | | - path, |
| 577 | | - workspace_root=None, |
| 578 | | - ) |
| 622 | + base_path, effective_pattern = _resolve_glob_base_and_pattern(pattern, path) |
| 579 | 623 | except FileNotFoundError: |
| 580 | 624 | return ToolResult(f"Directory not found: {path}", is_error=True) |
| 581 | 625 | except Exception as exc: |
| 582 | 626 | return ToolResult(f"Error resolving directory: {exc}", is_error=True) |
| 583 | 627 | |
| 584 | 628 | if not base_path.exists(): |
| 585 | | - return ToolResult(f"Directory not found: {path}", is_error=True) |
| 629 | + missing_target = path if path != "." else str(base_path) |
| 630 | + return ToolResult(f"Directory not found: {missing_target}", is_error=True) |
| 586 | 631 | |
| 587 | 632 | try: |
| 588 | | - matches = list(base_path.glob(pattern)) |
| 633 | + matches = list(base_path.glob(effective_pattern)) |
| 589 | 634 | # Sort by modification time (newest first) |
| 590 | 635 | matches.sort(key=lambda p: p.stat().st_mtime, reverse=True) |
| 591 | 636 | |
@@ -606,6 +651,8 @@ class GlobTool(Tool): |
| 606 | 651 | output, |
| 607 | 652 | metadata={ |
| 608 | 653 | "base_path": str(base_path), |
| 654 | + "effective_pattern": effective_pattern, |
| 655 | + "requested_pattern": pattern, |
| 609 | 656 | "num_files": len(matches), |
| 610 | 657 | "truncated": truncated if "truncated" in locals() else False, |
| 611 | 658 | }, |