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>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
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}")
|
||||
Reference in New Issue
Block a user