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
@@ -0,0 +1,7 @@
"""
File operation tools.
"""
from src.domains.tools.file.read import ReadFileTool
from src.domains.tools.file.glob import GlobFilesTool
__all__ = ["ReadFileTool", "GlobFilesTool"]
@@ -0,0 +1,83 @@
<!--
name: 'Tool Description: ReadFile'
description: Tool description for reading files
ccVersion: 2.0.14
variables:
- DEFAULT_READ_LINES
- MAX_LINE_LENGTH
- CAN_READ_PDF_FILES
- BASH_TOOL_NAME
-->
Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to ${DEFAULT_READ_LINES} lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than ${MAX_LINE_LENGTH} characters will be truncated
- Results are returned using cat -n format, with line numbers starting at 1
- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.${CAN_READ_PDF_FILES()?`
- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.`:""}
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
- This tool can only read files, not directories. To read a directory, use an ls command via the ${BASH_TOOL_NAME} tool.
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
---
<!--
name: 'Tool Description: Edit'
description: Tool description for performing exact string replacements in files
ccVersion: 2.0.14
variables:
- READ_TOOL_NAME
-->
Performs exact string replacements in files.
Usage:
- You must use your \`${READ_TOOL_NAME}\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
---
<!--
name: 'Tool Description: Write'
description: Tool description creating/overwriting writing individual files
ccVersion: 2.0.14
variables:
- READ_TOOL_NAME
-->
Writes a file to the local filesystem.
Usage:
- This tool will overwrite the existing file if there is one at the provided path.
- If this is an existing file, you MUST use the ${READ_TOOL_NAME} tool first to read the file's contents. This tool will fail if you did not read the file first.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
---
<!--
name: 'Tool Description: Glob'
description: Tool description for file pattern matching and searching by name
ccVersion: 2.0.14
-->
- Fast file pattern matching tool that works with any codebase size
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
- Returns matching file paths sorted by modification time
- Use this tool when you need to find files by name patterns
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead
- You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful.
+149
View File
@@ -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}")
+127
View File
@@ -0,0 +1,127 @@
"""
File reading tool with line number formatting and sandboxing.
"""
import aiofiles
from pathlib import Path
from src.domains.tools.base import BaseTool, ToolResult
from src.shared.logging import logged, get_logger
logger = get_logger(__name__)
class ReadFileTool(BaseTool):
"""
Read file contents with line numbers.
Supports offset and limit for handling large files.
Returns content in a format similar to `cat -n`.
"""
name = "read_file"
description = """Read contents of a file with line numbers.
Args:
file_path: Absolute path to the file to read
offset: Line number to start from (0-based, default: 0)
limit: Maximum number of lines to read (default: 2000)
Returns:
File contents with line numbers in format " 123| content"
Returns error if file not found or path not allowed.
IMPORTANT:
- Always use absolute paths
- Never estimate file contents - use this tool to verify
- Check if truncated flag is set for large files
"""
def __init__(
self,
allowed_paths: list[str] | None = None,
max_lines: int = 2000,
max_line_length: int = 2000
):
"""
Initialize ReadFileTool.
Args:
allowed_paths: List of allowed directory prefixes (empty = no restrictions)
max_lines: Default maximum lines to read
max_line_length: Maximum characters per line before truncation
"""
self.allowed_paths = allowed_paths or []
self.max_lines = max_lines
self.max_line_length = max_line_length
@logged()
async def execute(
self,
file_path: str,
offset: int = 0,
limit: int | None = None
) -> ToolResult:
"""
Read file contents with line numbers.
Args:
file_path: Absolute path to the file
offset: Starting line (0-based)
limit: Maximum lines to return
Returns:
ToolResult with formatted file contents or error
"""
limit = limit or self.max_lines
path = Path(file_path)
# Validate path is allowed
if not self._validate_path(path, self.allowed_paths):
return self._error(f"Path not in allowed paths: {file_path}")
# Check file exists
if not path.exists():
return self._error(f"File not found: {file_path}")
if not path.is_file():
return self._error(f"Not a file: {file_path}")
try:
async with aiofiles.open(path, 'r', encoding='utf-8', errors='replace') as f:
content = await f.read()
lines = content.splitlines()
total_lines = len(lines)
# Apply offset and limit
selected = lines[offset:offset + limit]
truncated = total_lines > offset + limit
# Format with line numbers (right-aligned, 6 chars)
numbered_lines = []
for i, line in enumerate(selected):
line_num = offset + i + 1 # 1-based for display
# Truncate long lines
if len(line) > self.max_line_length:
line = line[:self.max_line_length] + "..."
numbered_lines.append(f"{line_num:>6}| {line}")
result = "\n".join(numbered_lines)
return self._success(
data=result,
truncated=truncated,
total_lines=total_lines,
lines_returned=len(selected),
offset=offset
)
except PermissionError:
return self._error(f"Permission denied: {file_path}")
except UnicodeDecodeError as e:
return self._error(f"Unable to decode file (not text?): {e}")
except Exception as e:
logger.exception(f"Error reading file: {file_path}")
return self._error(f"Error reading file: {e}")