refactor: reorganize into monorepo with separate subprojects
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 2m27s

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:
2026-01-10 10:37:47 +01:00
co-authored by Claude Opus 4.5
parent f4e8552298
commit 3b58fa4f8b
121 changed files with 2034 additions and 284 deletions
+250
View File
@@ -0,0 +1,250 @@
"""
Content search tool using regex patterns.
"""
import re
from pathlib import Path
from typing import Literal
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 GrepContentTool(BaseTool):
"""
Search file contents using regex patterns.
Similar to grep/ripgrep but implemented in Python for portability.
"""
name = "grep_content"
description = """Search file contents using regex pattern.
Args:
pattern: Regex pattern to search for (Python re syntax)
path: Directory or file to search (default: working directory)
file_glob: Filter files by glob pattern (e.g., "*.py", "*.ts")
context_lines: Lines of context before/after matches (default: 0)
case_sensitive: Whether search is case-sensitive (default: True)
output_mode: "content" for matching lines, "files" for file paths only
honor_gitignore: Filter out gitignored files (default: True)
Returns:
Matching lines with file paths and line numbers, or list of files.
Format: "filepath:line_num: content"
By default, excludes files matching .gitignore patterns and common ignored
directories like .venv/, node_modules/, __pycache__/, etc.
Examples:
- pattern="def.*init" file_glob="*.py" - Find init methods in Python files
- pattern="TODO" - Find all TODO comments
- pattern="class\\s+\\w+" - Find class definitions
IMPORTANT:
- Use this tool to search for code patterns
- Escape special regex characters (\\, ., *, etc.)
- Never guess where code is - use grep to find it
"""
def __init__(
self,
allowed_paths: list[str] | None = None,
max_results: int = 100,
max_file_size: int = 1_000_000, # 1MB
honor_gitignore: bool = True
):
"""
Initialize GrepContentTool.
Args:
allowed_paths: List of allowed directory prefixes
max_results: Maximum matches to return
max_file_size: Skip files larger than this (bytes)
honor_gitignore: Whether to filter out gitignored files by default
"""
self.allowed_paths = allowed_paths or []
self.max_results = max_results
self.max_file_size = max_file_size
self.honor_gitignore = honor_gitignore
@logged()
async def execute(
self,
pattern: str,
path: str | None = None,
file_glob: str | None = None,
context_lines: int = 0,
case_sensitive: bool = True,
output_mode: Literal["content", "files"] = "content",
honor_gitignore: bool | None = None
) -> ToolResult:
"""
Search for pattern in files.
Args:
pattern: Regex pattern to search
path: Directory or file to search
file_glob: Filter to files matching glob
context_lines: Context lines around matches
case_sensitive: Case-sensitive search
output_mode: "content" or "files"
honor_gitignore: Filter out gitignored files (default: instance setting)
Returns:
ToolResult with matching content or file list
"""
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 path
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"Path not found: {search_path}")
# Compile regex
try:
flags = 0 if case_sensitive else re.IGNORECASE
regex = re.compile(pattern, flags)
except re.error as e:
return self._error(f"Invalid regex pattern: {e}")
# Collect files to search
if search_path.is_file():
files_to_search = [search_path]
else:
glob_pattern = file_glob or "**/*"
files_to_search = [
f for f in search_path.glob(glob_pattern)
if f.is_file()
]
# Filter by allowed paths
if self.allowed_paths:
files_to_search = [
f for f in files_to_search
if self._validate_path(f, self.allowed_paths)
]
# Filter out gitignored files
if should_filter_gitignore:
files_to_search = filter_gitignored(files_to_search, search_path)
# Search files
matches = []
files_with_matches = set()
total_matches = 0
for file_path in files_to_search:
# Skip large files
try:
if file_path.stat().st_size > self.max_file_size:
continue
except OSError:
continue
# Skip binary files (heuristic)
if self._is_likely_binary(file_path):
continue
file_matches = await self._search_file(
file_path, regex, context_lines
)
if file_matches:
files_with_matches.add(str(file_path.resolve()))
total_matches += len(file_matches)
matches.extend(file_matches)
# Check result limit
if len(matches) >= self.max_results:
break
# Format output
truncated = total_matches > self.max_results
if output_mode == "files":
result = "\n".join(sorted(files_with_matches))
if not result:
result = f"No files found matching pattern '{pattern}'"
else:
result = "\n".join(matches[:self.max_results])
if not result:
result = f"No matches found for pattern '{pattern}'"
return self._success(
data=result,
truncated=truncated,
total_matches=total_matches,
files_matched=len(files_with_matches)
)
async def _search_file(
self,
file_path: Path,
regex: re.Pattern,
context_lines: int
) -> list[str]:
"""Search a single file for matches."""
try:
content = file_path.read_text(encoding='utf-8', errors='replace')
lines = content.splitlines()
except (PermissionError, UnicodeDecodeError, OSError):
return []
matches = []
matched_line_nums = set()
# Find all matching lines
for i, line in enumerate(lines):
if regex.search(line):
matched_line_nums.add(i)
# Add context and format
for match_num in sorted(matched_line_nums):
start = max(0, match_num - context_lines)
end = min(len(lines), match_num + context_lines + 1)
for i in range(start, end):
prefix = ">" if i == match_num else " "
line_num = i + 1 # 1-based
formatted = f"{file_path}:{line_num}:{prefix} {lines[i]}"
matches.append(formatted)
# Add separator between match groups
if context_lines > 0:
matches.append("--")
# Remove trailing separator
if matches and matches[-1] == "--":
matches.pop()
return matches
def _is_likely_binary(self, file_path: Path) -> bool:
"""Check if file is likely binary based on extension or content."""
binary_extensions = {
'.pyc', '.pyo', '.so', '.dll', '.exe', '.bin',
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg',
'.pdf', '.zip', '.tar', '.gz', '.bz2', '.xz',
'.woff', '.woff2', '.ttf', '.eot',
'.mp3', '.mp4', '.wav', '.avi', '.mov',
'.db', '.sqlite', '.sqlite3',
}
if file_path.suffix.lower() in binary_extensions:
return True
# Check first bytes for null characters
try:
with open(file_path, 'rb') as f:
chunk = f.read(1024)
if b'\x00' in chunk:
return True
except (PermissionError, OSError):
return True
return False