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>
150 lines
4.9 KiB
Python
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}")
|