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,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.
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user