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