Files
webber/webber-api/src/domains/tools/file/glob.py
T
jpmschweitzerandClaude Opus 4.5 3b58fa4f8b
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 2m27s
refactor: reorganize into monorepo with separate subprojects
Structure webber into three independent subprojects:
- webber-api/: FastAPI backend server with all agent code
- webber-cli/: Standalone CLI client (renamed from cli/ to webber_cli/)
- webber-sandbox/: Test project for functional testing

Key changes:
- Each subproject has its own .venv (Python 3.12+)
- Added sandbox.sh for managing test project templates
- Created sandbox-templates/ with calculator-cli and empty starter
- Updated CI/CD for prefixed tags (api/v*, cli/v*)
- Added comprehensive AGENTS.md with operational instructions
- Added gitignore filtering to glob and grep tools
- Created pyproject.toml for each subproject

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 10:37:47 +01:00

150 lines
4.9 KiB
Python

"""
File glob/pattern matching tool.
"""
import os
from pathlib import Path
from src.domains.tools.base import BaseTool, ToolResult
from src.domains.tools.gitignore import filter_gitignored
from src.shared.logging import logged, get_logger
logger = get_logger(__name__)
class GlobFilesTool(BaseTool):
"""
Find files matching a glob pattern.
Returns files sorted by modification time (newest first).
"""
name = "glob_files"
description = """Find files matching a glob pattern.
Args:
pattern: Glob pattern (e.g., "**/*.py", "src/**/*.ts", "*.md")
path: Directory to search in (default: working directory)
limit: Maximum number of files to return (default: 100)
honor_gitignore: Filter out gitignored files (default: True)
Returns:
List of matching absolute file paths, sorted by modification time (newest first).
Returns error if path not found or not allowed.
By default, excludes files matching .gitignore patterns and common ignored
directories like .venv/, node_modules/, __pycache__/, etc.
Examples:
- "**/*.py" - All Python files recursively
- "src/**/*.ts" - TypeScript files in src
- "*.md" - Markdown files in current directory only
- "**/test_*.py" - All test files
IMPORTANT:
- Use this tool to find files before reading them
- Never guess file locations - use glob to discover
"""
def __init__(
self,
allowed_paths: list[str] | None = None,
max_results: int = 100,
honor_gitignore: bool = True
):
"""
Initialize GlobFilesTool.
Args:
allowed_paths: List of allowed directory prefixes
max_results: Maximum files to return
honor_gitignore: Whether to filter out gitignored files by default
"""
self.allowed_paths = allowed_paths or []
self.max_results = max_results
self.honor_gitignore = honor_gitignore
@logged()
async def execute(
self,
pattern: str,
path: str | None = None,
limit: int | None = None,
honor_gitignore: bool | None = None
) -> ToolResult:
"""
Find files matching glob pattern.
Args:
pattern: Glob pattern to match
path: Directory to search (default: current directory)
limit: Maximum results to return
honor_gitignore: Filter out gitignored files (default: instance setting)
Returns:
ToolResult with list of matching file paths
"""
limit = limit or self.max_results
should_filter_gitignore = honor_gitignore if honor_gitignore is not None else self.honor_gitignore
search_path = Path(path) if path else Path.cwd()
# Validate search path is allowed
if not self._validate_path(search_path, self.allowed_paths):
return self._error(f"Path not in allowed paths: {search_path}")
if not search_path.exists():
return self._error(f"Directory not found: {search_path}")
if not search_path.is_dir():
return self._error(f"Not a directory: {search_path}")
try:
# Find matching files
matches = list(search_path.glob(pattern))
# Filter to files only (exclude directories)
files = [f for f in matches if f.is_file()]
# Validate each result is in allowed paths
if self.allowed_paths:
files = [f for f in files if self._validate_path(f, self.allowed_paths)]
# Filter out gitignored files
if should_filter_gitignore:
files = filter_gitignored(files, search_path)
# Sort by modification time (newest first)
files_with_mtime = []
for f in files:
try:
mtime = os.path.getmtime(f)
files_with_mtime.append((f, mtime))
except OSError:
# Skip files we can't stat
continue
files_with_mtime.sort(key=lambda x: x[1], reverse=True)
sorted_files = [f for f, _ in files_with_mtime]
# Apply limit
truncated = len(sorted_files) > limit
result_files = sorted_files[:limit]
# Format output as absolute paths
output_lines = [str(f.resolve()) for f in result_files]
result = "\n".join(output_lines)
if not output_lines:
result = f"No files found matching '{pattern}' in {search_path}"
return self._success(
data=result,
truncated=truncated,
total_matches=len(sorted_files),
returned=len(result_files)
)
except PermissionError:
return self._error(f"Permission denied: {search_path}")
except Exception as e:
logger.exception(f"Error globbing: {pattern} in {search_path}")
return self._error(f"Error searching files: {e}")