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,170 @@
|
||||
# Tools Domain
|
||||
|
||||
This domain contains tool implementations for agent use.
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### File Tools (`file/`)
|
||||
|
||||
Tools for reading, writing, and finding files.
|
||||
|
||||
| Tool | Purpose | Key Parameters |
|
||||
|------|---------|----------------|
|
||||
| **Read** | Read file contents | `file_path`, `offset`, `limit` |
|
||||
| **Write** | Create/overwrite files | `file_path`, `content` |
|
||||
| **Edit** | Exact string replacement | `file_path`, `old_string`, `new_string`, `replace_all` |
|
||||
| **Glob** | Find files by pattern | `pattern`, `path` |
|
||||
|
||||
**Read Tool:**
|
||||
- Returns line-numbered content (`cat -n` format)
|
||||
- Supports offset/limit for large files
|
||||
- Can read images, PDFs, Jupyter notebooks
|
||||
- Default: 2000 lines, 2000 chars per line
|
||||
|
||||
**Edit Tool:**
|
||||
- Performs exact string replacements
|
||||
- Fails if `old_string` is not unique (use `replace_all` or provide more context)
|
||||
- Preserves indentation from Read output
|
||||
|
||||
**Glob Tool:**
|
||||
- Supports patterns like `**/*.py`, `src/**/*.ts`
|
||||
- Returns files sorted by modification time
|
||||
- Use for finding files by name patterns
|
||||
|
||||
---
|
||||
|
||||
### Shell Tools (`shell/`)
|
||||
|
||||
Tools for executing system commands.
|
||||
|
||||
| Tool | Purpose | Key Parameters |
|
||||
|------|---------|----------------|
|
||||
| **Bash** | Execute shell commands | `command`, `timeout`, `description` |
|
||||
|
||||
**Bash Tool:**
|
||||
- Persistent shell session
|
||||
- 2-minute default timeout (max 10 minutes)
|
||||
- Supports background execution (`run_in_background`)
|
||||
- Quote paths with spaces: `cd "/path with spaces"`
|
||||
|
||||
**Git Operations:**
|
||||
- Never commit to main/master directly
|
||||
- Use conventional commits format
|
||||
- Never use `-i` flag (interactive)
|
||||
- Never skip hooks unless explicitly requested
|
||||
- Never force push to main/master
|
||||
|
||||
**Security:**
|
||||
- Sandboxed execution when `SANDBOX_ENABLED=true`
|
||||
- Validates against `ALLOWED_PATHS`
|
||||
- Timeout enforcement
|
||||
|
||||
---
|
||||
|
||||
### Search Tools (`search/`)
|
||||
|
||||
Tools for searching content and the web.
|
||||
|
||||
| Tool | Purpose | Key Parameters |
|
||||
|------|---------|----------------|
|
||||
| **Grep** | Search file contents | `pattern`, `path`, `glob`, `output_mode` |
|
||||
| **WebSearch** | Search the web | `query`, `allowed_domains`, `blocked_domains` |
|
||||
| **WebFetch** | Fetch and analyze URLs | `url`, `prompt` |
|
||||
|
||||
**Grep Tool:**
|
||||
- Built on ripgrep (NOT grep/rg bash commands)
|
||||
- Supports regex patterns
|
||||
- Output modes: `files_with_matches` (default), `content`, `count`
|
||||
- Context lines: `-A`, `-B`, `-C`
|
||||
|
||||
**WebSearch Tool:**
|
||||
- Returns search results with URLs
|
||||
- Always include sources in responses
|
||||
- Domain filtering supported
|
||||
|
||||
**WebFetch Tool:**
|
||||
- Fetches URL, converts HTML to markdown
|
||||
- Processes content with AI for extraction
|
||||
- 15-minute cache for repeated URLs
|
||||
- Handles redirects (returns redirect URL)
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
tools/
|
||||
├── router.py # Tool routes (list, execute)
|
||||
├── controller.py # Tool orchestration
|
||||
├── schemas.py # Tool request/response models
|
||||
│
|
||||
├── file/ # File operation tools
|
||||
│ ├── __init__.py
|
||||
│ ├── read.py # Read file contents
|
||||
│ ├── write.py # Write file contents
|
||||
│ ├── edit.py # Edit file contents
|
||||
│ ├── glob.py # Find files by pattern
|
||||
│ └── example-prompt.md
|
||||
│
|
||||
├── shell/ # Shell execution tools
|
||||
│ ├── __init__.py
|
||||
│ ├── bash.py # Execute bash commands
|
||||
│ └── example-prompt.md
|
||||
│
|
||||
└── search/ # Search tools
|
||||
├── __init__.py
|
||||
├── grep.py # Search file contents
|
||||
├── web.py # Web search and fetch
|
||||
└── example-prompt.md
|
||||
```
|
||||
|
||||
## Tool Pattern
|
||||
|
||||
Tools are registered with PydanticAI agents via the `@agent.tool` decorator.
|
||||
Each tool should:
|
||||
|
||||
1. Have clear input/output types
|
||||
2. Include a docstring (used by LLM)
|
||||
3. Handle errors gracefully
|
||||
4. Respect sandbox settings
|
||||
|
||||
```python
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import logged
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@logged()
|
||||
async def read_file(file_path: str, limit: int = 2000) -> str:
|
||||
"""
|
||||
Read contents of a file.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the file
|
||||
limit: Maximum lines to read
|
||||
|
||||
Returns:
|
||||
File contents as string with line numbers
|
||||
"""
|
||||
# Check path is allowed
|
||||
if settings.sandbox_enabled:
|
||||
_validate_path(file_path, settings.allowed_paths)
|
||||
|
||||
# Read and return with line numbers
|
||||
pass
|
||||
```
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
1. Create a new file in appropriate category (file/, shell/, search/)
|
||||
2. Implement the tool function with proper types and docstring
|
||||
3. Add `@logged()` decorator for timing
|
||||
4. Handle sandbox restrictions
|
||||
5. Register with agents that need it
|
||||
6. Add tests
|
||||
|
||||
## Reference Prompts
|
||||
|
||||
Each tool category directory contains an `example-prompt.md` file with reference
|
||||
prompts from the claude-code-system-prompts repository. These document the expected
|
||||
behavior and usage patterns for each tool.
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Tool implementations for agent use.
|
||||
|
||||
All tools inherit from BaseTool and return ToolResult.
|
||||
"""
|
||||
from src.domains.tools.base import BaseTool, ToolResult
|
||||
from src.domains.tools.file import ReadFileTool, GlobFilesTool
|
||||
from src.domains.tools.search import GrepContentTool
|
||||
from src.domains.tools.shell import BashReadOnlyTool
|
||||
|
||||
__all__ = [
|
||||
"BaseTool",
|
||||
"ToolResult",
|
||||
"ReadFileTool",
|
||||
"GlobFilesTool",
|
||||
"GrepContentTool",
|
||||
"BashReadOnlyTool",
|
||||
]
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Base classes for tool implementations.
|
||||
|
||||
All tools inherit from BaseTool and return ToolResult for consistent handling.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""
|
||||
Standardized result from tool execution.
|
||||
|
||||
All tools return this for consistent error handling and LLM consumption.
|
||||
"""
|
||||
success: bool
|
||||
data: Any
|
||||
error: str | None = None
|
||||
truncated: bool = False
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_string(self, max_length: int = 30000) -> str:
|
||||
"""
|
||||
Convert result to string for LLM consumption.
|
||||
|
||||
Args:
|
||||
max_length: Maximum string length before truncation
|
||||
"""
|
||||
if not self.success:
|
||||
return f"ERROR: {self.error}"
|
||||
|
||||
if isinstance(self.data, str):
|
||||
content = self.data
|
||||
else:
|
||||
content = str(self.data)
|
||||
|
||||
if len(content) > max_length:
|
||||
self.truncated = True
|
||||
content = content[:max_length] + "\n... (truncated)"
|
||||
|
||||
if self.truncated:
|
||||
content += "\n[Output was truncated]"
|
||||
|
||||
return content
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.to_string()
|
||||
|
||||
|
||||
class BaseTool(ABC):
|
||||
"""
|
||||
Abstract base class for all tools.
|
||||
|
||||
All domain tools (file, shell, search) inherit from this and implement execute().
|
||||
|
||||
Usage:
|
||||
class MyTool(BaseTool):
|
||||
name = "my_tool"
|
||||
description = "Does something useful"
|
||||
|
||||
async def execute(self, **kwargs) -> ToolResult:
|
||||
return ToolResult(success=True, data="result")
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Tool name for registration and identification."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""
|
||||
Tool description for LLM.
|
||||
|
||||
Should include:
|
||||
- What the tool does
|
||||
- Arguments and their types
|
||||
- Return value description
|
||||
- Usage constraints/examples
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, **kwargs: Any) -> ToolResult:
|
||||
"""
|
||||
Execute the tool with given arguments.
|
||||
|
||||
Returns:
|
||||
ToolResult with success status and data or error
|
||||
"""
|
||||
pass
|
||||
|
||||
def _validate_path(self, path: str | Path, allowed_paths: list[str]) -> bool:
|
||||
"""
|
||||
Validate that a path is within allowed directories.
|
||||
|
||||
Args:
|
||||
path: Path to validate
|
||||
allowed_paths: List of allowed directory prefixes
|
||||
|
||||
Returns:
|
||||
True if path is allowed, False otherwise
|
||||
"""
|
||||
if not allowed_paths:
|
||||
return True # No restrictions when allowed_paths is empty
|
||||
|
||||
resolved = Path(path).resolve()
|
||||
return any(
|
||||
str(resolved).startswith(str(Path(allowed).resolve()))
|
||||
for allowed in allowed_paths
|
||||
)
|
||||
|
||||
def _error(self, message: str) -> ToolResult:
|
||||
"""Create an error result."""
|
||||
return ToolResult(success=False, data=None, error=message)
|
||||
|
||||
def _success(
|
||||
self,
|
||||
data: Any,
|
||||
truncated: bool = False,
|
||||
**metadata: Any
|
||||
) -> ToolResult:
|
||||
"""Create a success result."""
|
||||
return ToolResult(
|
||||
success=True,
|
||||
data=data,
|
||||
truncated=truncated,
|
||||
metadata=metadata
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Gitignore pattern matching for tool filtering.
|
||||
|
||||
Uses pathspec to parse .gitignore files and filter out ignored paths.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pathspec
|
||||
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GitignoreFilter:
|
||||
"""
|
||||
Filter files based on .gitignore patterns.
|
||||
|
||||
Parses .gitignore files from the root directory and any parent directories,
|
||||
then provides methods to check if paths should be ignored.
|
||||
"""
|
||||
|
||||
def __init__(self, root_dir: str | Path):
|
||||
"""
|
||||
Initialize GitignoreFilter for a directory.
|
||||
|
||||
Args:
|
||||
root_dir: Root directory to search for .gitignore files
|
||||
"""
|
||||
self.root_dir = Path(root_dir).resolve()
|
||||
self._spec: pathspec.PathSpec | None = None
|
||||
self._load_patterns()
|
||||
|
||||
def _load_patterns(self) -> None:
|
||||
"""Load gitignore patterns from .gitignore files."""
|
||||
patterns: list[str] = []
|
||||
|
||||
# Always ignore common directories that should never be searched
|
||||
default_ignores = [
|
||||
".git/",
|
||||
".venv/",
|
||||
"venv/",
|
||||
"__pycache__/",
|
||||
"*.pyc",
|
||||
".mypy_cache/",
|
||||
".pytest_cache/",
|
||||
".ruff_cache/",
|
||||
"node_modules/",
|
||||
".tox/",
|
||||
".nox/",
|
||||
"*.egg-info/",
|
||||
"dist/",
|
||||
"build/",
|
||||
".eggs/",
|
||||
]
|
||||
patterns.extend(default_ignores)
|
||||
|
||||
# Find and parse .gitignore in root directory
|
||||
gitignore_path = self.root_dir / ".gitignore"
|
||||
if gitignore_path.exists():
|
||||
try:
|
||||
content = gitignore_path.read_text(encoding="utf-8")
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if line and not line.startswith("#"):
|
||||
patterns.append(line)
|
||||
logger.debug(f"Loaded {len(patterns)} patterns from {gitignore_path}")
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
logger.warning(f"Failed to read .gitignore: {e}")
|
||||
|
||||
# Create pathspec matcher
|
||||
self._spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
||||
|
||||
def is_ignored(self, path: str | Path) -> bool:
|
||||
"""
|
||||
Check if a path should be ignored.
|
||||
|
||||
Args:
|
||||
path: Absolute or relative path to check
|
||||
|
||||
Returns:
|
||||
True if the path matches gitignore patterns
|
||||
"""
|
||||
if self._spec is None:
|
||||
return False
|
||||
|
||||
path = Path(path)
|
||||
|
||||
# Make path relative to root for matching
|
||||
try:
|
||||
if path.is_absolute():
|
||||
rel_path = path.resolve().relative_to(self.root_dir)
|
||||
else:
|
||||
rel_path = path
|
||||
except ValueError:
|
||||
# Path is not under root_dir, don't filter
|
||||
return False
|
||||
|
||||
# Convert to string with forward slashes for pathspec
|
||||
path_str = str(rel_path).replace("\\", "/")
|
||||
|
||||
# Check if it's a directory (add trailing slash for directory patterns)
|
||||
if path.is_dir():
|
||||
path_str_dir = path_str + "/"
|
||||
return self._spec.match_file(path_str) or self._spec.match_file(path_str_dir)
|
||||
|
||||
return self._spec.match_file(path_str)
|
||||
|
||||
def filter_paths(self, paths: list[Path]) -> list[Path]:
|
||||
"""
|
||||
Filter a list of paths, removing ignored ones.
|
||||
|
||||
Args:
|
||||
paths: List of Path objects to filter
|
||||
|
||||
Returns:
|
||||
List of paths that are not ignored
|
||||
"""
|
||||
return [p for p in paths if not self.is_ignored(p)]
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def get_gitignore_filter(root_dir: str) -> GitignoreFilter:
|
||||
"""
|
||||
Get a cached GitignoreFilter for a directory.
|
||||
|
||||
Uses LRU cache to avoid re-parsing .gitignore for repeated calls.
|
||||
|
||||
Args:
|
||||
root_dir: Root directory path (string for cache key)
|
||||
|
||||
Returns:
|
||||
GitignoreFilter instance
|
||||
"""
|
||||
return GitignoreFilter(root_dir)
|
||||
|
||||
|
||||
def filter_gitignored(paths: list[Path], root_dir: str | Path) -> list[Path]:
|
||||
"""
|
||||
Convenience function to filter paths using gitignore patterns.
|
||||
|
||||
Args:
|
||||
paths: List of paths to filter
|
||||
root_dir: Root directory containing .gitignore
|
||||
|
||||
Returns:
|
||||
Filtered list of paths
|
||||
"""
|
||||
filter_instance = get_gitignore_filter(str(Path(root_dir).resolve()))
|
||||
return filter_instance.filter_paths(paths)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Search tools.
|
||||
"""
|
||||
from src.domains.tools.search.grep import GrepContentTool
|
||||
|
||||
__all__ = ["GrepContentTool"]
|
||||
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
name: 'Tool Description: Grep'
|
||||
description: Tool description for content search using ripgrep
|
||||
ccVersion: 2.0.14
|
||||
variables:
|
||||
- GREP_TOOL_NAME
|
||||
- BASH_TOOL_NAME
|
||||
- TASK_TOOL_NAME
|
||||
-->
|
||||
A powerful search tool built on ripgrep
|
||||
|
||||
Usage:
|
||||
- ALWAYS use ${GREP_TOOL_NAME} for search tasks. NEVER invoke \`grep\` or \`rg\` as a ${BASH_TOOL_NAME} command. The ${GREP_TOOL_NAME} tool has been optimized for correct permissions and access.
|
||||
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
|
||||
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
|
||||
- Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts
|
||||
- Use ${TASK_TOOL_NAME} tool for open-ended searches requiring multiple rounds
|
||||
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code)
|
||||
- Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\`
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: WebSearch'
|
||||
description: Tool description for web search functionality
|
||||
ccVersion: 2.0.56
|
||||
variables:
|
||||
- GET_CURRENT_DATE_FN
|
||||
-->
|
||||
|
||||
- Allows Claude to search the web and use the results to inform responses
|
||||
- Provides up-to-date information for current events and recent data
|
||||
- Returns search result information formatted as search result blocks, including links as markdown hyperlinks
|
||||
- Use this tool for accessing information beyond Claude's knowledge cutoff
|
||||
- Searches are performed automatically within a single API call
|
||||
|
||||
CRITICAL REQUIREMENT - You MUST follow this:
|
||||
- After answering the user's question, you MUST include a "Sources:" section at the end of your response
|
||||
- In the Sources section, list all relevant URLs from the search results as markdown hyperlinks: [Title](URL)
|
||||
- This is MANDATORY - never skip including sources in your response
|
||||
- Example format:
|
||||
|
||||
[Your answer here]
|
||||
|
||||
Sources:
|
||||
- [Source Title 1](https://example.com/1)
|
||||
- [Source Title 2](https://example.com/2)
|
||||
|
||||
Usage notes:
|
||||
- Domain filtering is supported to include or block specific websites
|
||||
- Web search is only available in the US
|
||||
|
||||
IMPORTANT - Use the correct year in search queries:
|
||||
- Today's date is ${GET_CURRENT_DATE_FN()}. You MUST use this year when searching for recent information, documentation, or current events.
|
||||
- Example: If today is 2025-07-15 and the user asks for "latest React docs", search for "React documentation 2025", NOT "React documentation 2024"
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: WebFetch'
|
||||
description: Tool description for web fetch functionality
|
||||
ccVersion: 2.0.62
|
||||
-->
|
||||
|
||||
- Fetches content from a specified URL and processes it using an AI model
|
||||
- Takes a URL and a prompt as input
|
||||
- Fetches the URL content, converts HTML to markdown
|
||||
- Processes the content with the prompt using a small, fast model
|
||||
- Returns the model's response about the content
|
||||
- Use this tool when you need to retrieve and analyze web content
|
||||
|
||||
Usage notes:
|
||||
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions.
|
||||
- The URL must be a fully-formed valid URL
|
||||
- HTTP URLs will be automatically upgraded to HTTPS
|
||||
- The prompt should describe what information you want to extract from the page
|
||||
- This tool is read-only and does not modify any files
|
||||
- Results may be summarized if the content is very large
|
||||
- Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL
|
||||
- When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Shell execution tools.
|
||||
"""
|
||||
from src.domains.tools.shell.bash import BashReadOnlyTool
|
||||
|
||||
__all__ = ["BashReadOnlyTool"]
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Read-only bash command execution tool.
|
||||
|
||||
Only allows safe, read-only commands to prevent accidental damage.
|
||||
"""
|
||||
import asyncio
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
from src.domains.tools.base import BaseTool, ToolResult
|
||||
from src.shared.logging import logged, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Commands that are allowed in read-only mode
|
||||
ALLOWED_COMMANDS = {
|
||||
# File inspection
|
||||
"ls", "find", "cat", "head", "tail", "wc", "file", "stat",
|
||||
"tree", "du", "df",
|
||||
# Text processing (read-only)
|
||||
"grep", "awk", "sed", "sort", "uniq", "cut", "tr",
|
||||
# Git (read-only operations)
|
||||
"git",
|
||||
# System info
|
||||
"pwd", "whoami", "hostname", "uname", "date", "env", "printenv",
|
||||
"which", "type", "echo",
|
||||
# Archive inspection
|
||||
"tar", "unzip", "zipinfo",
|
||||
}
|
||||
|
||||
# Git subcommands that are allowed (read-only)
|
||||
ALLOWED_GIT_SUBCOMMANDS = {
|
||||
"status", "log", "diff", "show", "branch", "tag",
|
||||
"remote", "config", "ls-files", "ls-tree",
|
||||
"rev-parse", "describe", "shortlog", "blame",
|
||||
}
|
||||
|
||||
# Patterns that are never allowed (security)
|
||||
FORBIDDEN_PATTERNS = [
|
||||
# Destructive redirects
|
||||
">", ">>",
|
||||
# Command chaining (could bypass checks)
|
||||
"&&", "||", ";",
|
||||
# Subshells
|
||||
"$(", "`",
|
||||
# Explicit destructive commands
|
||||
"rm ", "rm\t", "rmdir",
|
||||
"mv ", "mv\t",
|
||||
"cp ", "cp\t",
|
||||
"mkdir", "touch",
|
||||
# Package managers
|
||||
"pip", "npm", "yarn", "apt", "yum", "brew",
|
||||
# Network
|
||||
"curl", "wget", "ssh", "scp",
|
||||
# Process control
|
||||
"kill", "pkill", "killall",
|
||||
]
|
||||
|
||||
|
||||
class BashReadOnlyTool(BaseTool):
|
||||
"""
|
||||
Execute read-only bash commands safely.
|
||||
|
||||
Only allows a curated set of commands that cannot modify the filesystem.
|
||||
"""
|
||||
|
||||
name = "bash_readonly"
|
||||
description = """Execute a read-only bash command.
|
||||
|
||||
ALLOWED commands:
|
||||
- File inspection: ls, find, cat, head, tail, wc, file, stat, tree, du
|
||||
- Git (read-only): git status, git log, git diff, git show, git branch
|
||||
- Text processing: grep, awk, sed (read-only), sort, uniq, cut
|
||||
- System info: pwd, whoami, hostname, uname, date, which
|
||||
|
||||
FORBIDDEN:
|
||||
- Any file modification (rm, mv, cp, mkdir, touch)
|
||||
- Redirects (>, >>)
|
||||
- Command chaining (&&, ||, ;)
|
||||
- Package managers (pip, npm, apt)
|
||||
- Network commands (curl, wget, ssh)
|
||||
|
||||
Args:
|
||||
command: The bash command to execute
|
||||
cwd: Working directory for the command (default: current directory)
|
||||
timeout: Timeout in seconds (default: 30)
|
||||
|
||||
Returns:
|
||||
Command stdout on success, or error message.
|
||||
|
||||
Examples:
|
||||
- "ls -la" - List files with details
|
||||
- "git status" - Show git status
|
||||
- "find . -name '*.py' -type f" - Find Python files
|
||||
- "head -50 README.md" - First 50 lines of README
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allowed_paths: list[str] | None = None,
|
||||
default_timeout: int = 30,
|
||||
max_output_size: int = 50000
|
||||
):
|
||||
"""
|
||||
Initialize BashReadOnlyTool.
|
||||
|
||||
Args:
|
||||
allowed_paths: Allowed working directories
|
||||
default_timeout: Default command timeout in seconds
|
||||
max_output_size: Maximum output size in characters
|
||||
"""
|
||||
self.allowed_paths = allowed_paths or []
|
||||
self.default_timeout = default_timeout
|
||||
self.max_output_size = max_output_size
|
||||
|
||||
@logged()
|
||||
async def execute(
|
||||
self,
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
timeout: int | None = None
|
||||
) -> ToolResult:
|
||||
"""
|
||||
Execute a read-only bash command.
|
||||
|
||||
Args:
|
||||
command: Command to execute
|
||||
cwd: Working directory
|
||||
timeout: Timeout in seconds
|
||||
|
||||
Returns:
|
||||
ToolResult with command output or error
|
||||
"""
|
||||
timeout = timeout or self.default_timeout
|
||||
working_dir = Path(cwd) if cwd else Path.cwd()
|
||||
|
||||
# Validate working directory
|
||||
if not self._validate_path(working_dir, self.allowed_paths):
|
||||
return self._error(f"Working directory not allowed: {working_dir}")
|
||||
|
||||
if not working_dir.exists():
|
||||
return self._error(f"Working directory not found: {working_dir}")
|
||||
|
||||
# Security validation
|
||||
validation_error = self._validate_command(command)
|
||||
if validation_error:
|
||||
return self._error(validation_error)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(working_dir)
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
stdout_str = stdout.decode('utf-8', errors='replace')
|
||||
stderr_str = stderr.decode('utf-8', errors='replace')
|
||||
|
||||
# Truncate if necessary
|
||||
truncated = False
|
||||
if len(stdout_str) > self.max_output_size:
|
||||
stdout_str = stdout_str[:self.max_output_size]
|
||||
truncated = True
|
||||
|
||||
if proc.returncode != 0:
|
||||
# Command failed, return stderr
|
||||
error_msg = stderr_str or f"Command exited with code {proc.returncode}"
|
||||
return ToolResult(
|
||||
success=False,
|
||||
data=stdout_str if stdout_str else None,
|
||||
error=error_msg,
|
||||
truncated=truncated
|
||||
)
|
||||
|
||||
# Success - combine stdout and stderr if both present
|
||||
output = stdout_str
|
||||
if stderr_str and not output:
|
||||
output = stderr_str
|
||||
|
||||
return self._success(
|
||||
data=output,
|
||||
truncated=truncated,
|
||||
exit_code=proc.returncode
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return self._error(f"Command timed out after {timeout} seconds")
|
||||
except Exception as e:
|
||||
logger.exception(f"Error executing command: {command}")
|
||||
return self._error(f"Error executing command: {e}")
|
||||
|
||||
def _validate_command(self, command: str) -> str | None:
|
||||
"""
|
||||
Validate command is safe to execute.
|
||||
|
||||
Returns:
|
||||
Error message if invalid, None if valid
|
||||
"""
|
||||
# Check for forbidden patterns
|
||||
command_lower = command.lower()
|
||||
for pattern in FORBIDDEN_PATTERNS:
|
||||
if pattern in command_lower:
|
||||
return f"Command contains forbidden pattern: {pattern.strip()}"
|
||||
|
||||
# Parse command to get base command
|
||||
try:
|
||||
tokens = shlex.split(command)
|
||||
if not tokens:
|
||||
return "Empty command"
|
||||
except ValueError as e:
|
||||
return f"Invalid command syntax: {e}"
|
||||
|
||||
# Get base command (handle full paths)
|
||||
base_cmd = Path(tokens[0]).name
|
||||
|
||||
# Check if command is allowed
|
||||
if base_cmd not in ALLOWED_COMMANDS:
|
||||
return f"Command not allowed in read-only mode: {base_cmd}"
|
||||
|
||||
# Special handling for git - check subcommand
|
||||
if base_cmd == "git":
|
||||
if len(tokens) < 2:
|
||||
return "Git command requires a subcommand"
|
||||
|
||||
git_subcommand = tokens[1]
|
||||
if git_subcommand not in ALLOWED_GIT_SUBCOMMANDS:
|
||||
return f"Git subcommand not allowed: {git_subcommand}"
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,201 @@
|
||||
<!--
|
||||
name: 'Tool Description: Bash'
|
||||
description: Description for the Bash tool, which allows Claude to run shell commands
|
||||
ccVersion: 2.0.25
|
||||
variables:
|
||||
- CUSTOM_TIMEOUT_MS
|
||||
- MAX_TIMEOUT_MS
|
||||
- MAX_OUTPUT_CHARS
|
||||
- BASH_TOOL_NAME
|
||||
- BASH_TOOL_EXTRA_NOTES
|
||||
- SEARCH_TOOL_NAME
|
||||
- GREP_TOOL_NAME
|
||||
- READ_TOOL_NAME
|
||||
- EDIT_TOOL_NAME
|
||||
- WRITE_TOOL_NAME
|
||||
- GIT_COMMIT_AND_PR_CREATION_INSTRUCTION
|
||||
-->
|
||||
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
|
||||
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location
|
||||
- For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory
|
||||
|
||||
2. Command Execution:
|
||||
- Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt")
|
||||
- Examples of proper quoting:
|
||||
- cd "/Users/name/My Documents" (correct)
|
||||
- cd /Users/name/My Documents (incorrect - will fail)
|
||||
- python "/path/with spaces/script.py" (correct)
|
||||
- python /path/with spaces/script.py (incorrect - will fail)
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to ${CUSTOM_TIMEOUT_MS()}ms / ${CUSTOM_TIMEOUT_MS()/60000} minutes). If not specified, commands will timeout after ${MAX_TIMEOUT_MS()}ms (${MAX_TIMEOUT_MS()/60000} minutes).
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${MAX_OUTPUT_CHARS()} characters, output will be truncated before being returned to you.
|
||||
- You can use the \`run_in_background\` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the ${BASH_TOOL_NAME} tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
|
||||
${BASH_TOOL_EXTRA_NOTES()}
|
||||
- Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use ${SEARCH_TOOL_NAME} (NOT find or ls)
|
||||
- Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)
|
||||
- Read files: Use ${READ_TOOL_NAME} (NOT cat/head/tail)
|
||||
- Edit files: Use ${EDIT_TOOL_NAME} (NOT sed/awk)
|
||||
- Write files: Use ${WRITE_TOOL_NAME} (NOT echo >/cat <<EOF)
|
||||
- Communication: Output text directly (NOT echo/printf)
|
||||
- When issuing multiple commands:
|
||||
- If the commands are independent and can run in parallel, make multiple ${BASH_TOOL_NAME} tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two ${BASH_TOOL_NAME} tool calls in parallel.
|
||||
- If the commands depend on each other and must run sequentially, use a single ${BASH_TOOL_NAME} call with '&&' to chain them together (e.g., \`git add . && git commit -m "message" && git push\`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead.
|
||||
- Use ';' only when you need to run commands sequentially but don't care if earlier commands fail
|
||||
- DO NOT use newlines to separate commands (newlines are ok in quoted strings)
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of \`cd\`. You may use \`cd\` if the User explicitly requests it.
|
||||
<good-example>
|
||||
pytest /foo/bar/tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
|
||||
${GIT_COMMIT_AND_PR_CREATION_INSTRUCTION()}
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Git Commit and PR Instructions
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: Bash (Git commit and PR creation instructions)'
|
||||
description: Instructions for creating git commits and GitHub pull requests
|
||||
ccVersion: 2.0.74
|
||||
variables:
|
||||
- BASH_TOOL_NAME
|
||||
- COMMIT_CO_AUTHORED_BY_CLAUDE_CODE
|
||||
- TODO_TOOL_OBJECT
|
||||
- TASK_TOOL_NAME
|
||||
- PR_GENERATED_WITH_CLAUDE_CODE
|
||||
-->
|
||||
# Committing changes with git
|
||||
|
||||
Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
|
||||
|
||||
Git Safety Protocol:
|
||||
- NEVER update the git config
|
||||
- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
|
||||
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
||||
- NEVER run force push to main/master, warn the user if they request it
|
||||
- Avoid git commit --amend. ONLY use --amend when ALL conditions are met:
|
||||
(1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including
|
||||
(2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae')
|
||||
(3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead")
|
||||
- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit
|
||||
- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push)
|
||||
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the ${BASH_TOOL_NAME} tool:
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
||||
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
|
||||
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
|
||||
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
||||
- Ensure it accurately reflects the changes and their purpose
|
||||
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands:
|
||||
- Add relevant untracked files to the staging area.
|
||||
- Create the commit with a message${COMMIT_CO_AUTHORED_BY_CLAUDE_CODE?` ending with:
|
||||
${COMMIT_CO_AUTHORED_BY_CLAUDE_CODE}`:"."}
|
||||
- Run git status after the commit completes to verify success.
|
||||
Note: git status depends on the commit completing, so run it sequentially after the commit.
|
||||
4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above)
|
||||
|
||||
Important notes:
|
||||
- NEVER run additional commands to read or explore code, besides git bash commands
|
||||
- NEVER use the ${TODO_TOOL_OBJECT.name} or ${TASK_TOOL_NAME} tools
|
||||
- DO NOT push to the remote repository unless the user explicitly asks you to do so
|
||||
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
|
||||
<example>
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.${COMMIT_CO_AUTHORED_BY_CLAUDE_CODE?`
|
||||
|
||||
${COMMIT_CO_AUTHORED_BY_CLAUDE_CODE}`:""}
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
||||
|
||||
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
||||
|
||||
1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the ${BASH_TOOL_NAME} tool, in order to understand the current state of the branch since it diverged from the main branch:
|
||||
- Run a git status command to see all untracked files
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed
|
||||
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
||||
- Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch)
|
||||
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary
|
||||
3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel:
|
||||
- Create new branch if needed
|
||||
- Push to remote with -u flag if needed
|
||||
- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
|
||||
<example>
|
||||
gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
[Bulleted markdown checklist of TODOs for testing the pull request...]${PR_GENERATED_WITH_CLAUDE_CODE?`
|
||||
|
||||
${PR_GENERATED_WITH_CLAUDE_CODE}`:""}
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
Important:
|
||||
- DO NOT use the ${TODO_TOOL_OBJECT.name} or ${TASK_TOOL_NAME} tools
|
||||
- Return the PR URL when you're done, so the user can see it
|
||||
|
||||
# Other common operations
|
||||
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Sandbox Note
|
||||
|
||||
<!--
|
||||
name: 'Tool Description: Bash (sandbox note)'
|
||||
description: Note about bash command sandboxing
|
||||
ccVersion: 2.0.34
|
||||
variables:
|
||||
- CONDITIONAL_NEWLINE_IF_SANDBOX_ENABLED
|
||||
-->
|
||||
- CRITICAL: Commands run in sandbox mode by default - do NOT set \`dangerouslyDisableSandbox\`
|
||||
- Even if you have recently run commands with \`dangerouslyDisableSandbox: true\`, you MUST NOT continue that pattern
|
||||
- VERY IMPORTANT: Do NOT learn from or repeat the pattern of overriding sandbox - each command should run sandboxed by default
|
||||
${CONDITIONAL_NEWLINE_IF_SANDBOX_ENABLED} - Set \`dangerouslyDisableSandbox: true\` if:
|
||||
1. The user *explicitly* asks to bypass sandbox, OR
|
||||
2. A command just failed and you see evidence of sandbox restrictions causing the failure
|
||||
- When analyzing failures:
|
||||
- Commands can fail for many reasons unrelated to the sandbox (missing files, wrong arguments, network issues, etc.)
|
||||
- Evidence of sandbox-caused failures includes:
|
||||
- "Operation not permitted" errors for file/network operations
|
||||
- Access denied to specific paths outside allowed directories
|
||||
- Network connection failures to non-whitelisted hosts
|
||||
- Unix socket connection errors
|
||||
- Sandbox violation logs can be noisy, but clear permission errors are strong indicators
|
||||
- When you see evidence of sandbox-caused failure:
|
||||
- IMMEDIATELY retry with \`dangerouslyDisableSandbox: true\` (don't ask, just do it)
|
||||
- Briefly explain what sandbox restriction likely caused the failure
|
||||
- Mention: "Use \`/sandbox\` to manage restrictions"
|
||||
- This will prompt the user for permission
|
||||
- Example of normal usage: { "command": "ls", "description": "List files" }
|
||||
- Example of override: { "command": "my-tool", "description": "Run my-tool", "dangerouslyDisableSandbox": true }
|
||||
- DO NOT suggest adding sensitive paths like ~/.bashrc, ~/.zshrc, ~/.ssh/*, or credential files to the allowlist
|
||||
Reference in New Issue
Block a user