"""Central tool registry for folder tools."""
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol
import pathspec
from ..bot import BotContext, FolderBot
from .base import ToolResult
[docs]
class FolderServicesProtocol(Protocol):
"""Protocol for folder tool services/dependencies."""
@property
def root(self) -> Path:
"""Root folder path."""
...
@property
def config(self) -> Any:
"""Configuration object."""
...
@property
def activity_logger(self) -> Any:
"""Activity logger instance."""
...
@property
def session_manager(self) -> Any | None:
"""Session manager for user preferences."""
...
[docs]
def validate_path(self, relative_path: str) -> Path | None:
"""Validate and resolve a path within root folder."""
...
[docs]
def is_file_allowed(self, rel_path: str) -> bool:
"""Check if file matches include/exclude patterns."""
...
[docs]
def is_append_allowed(self, rel_path: str) -> bool:
"""Check if file matches append_allowed patterns."""
...
[docs]
@dataclass
class FolderServices:
"""Concrete implementation of folder services."""
root: Path
config: Any
activity_logger: Any
session_manager: Any | None = None
_include_spec: Any = field(default=None, repr=False)
_exclude_spec: Any = field(default=None, repr=False)
_append_spec: Any = field(default=None, repr=False)
def __post_init__(self) -> None:
"""Initialize path specs from config."""
self._include_spec = pathspec.PathSpec.from_lines(
"gitignore", self.config.read_rules.include
)
self._exclude_spec = pathspec.PathSpec.from_lines(
"gitignore", self.config.read_rules.exclude
)
self._append_spec = pathspec.PathSpec.from_lines(
"gitignore", self.config.read_rules.append_allowed
)
[docs]
def validate_path(self, relative_path: str) -> Path | None:
"""Validate a relative path, ensuring it stays within root folder.
Returns the logical (un-resolved) path so that symlinks inside the
root folder work transparently. Python's pathlib follows symlinks
automatically for I/O operations (read, write, exists, rglob, etc.).
Security is enforced by rejecting ``..`` components and absolute paths.
"""
if not relative_path:
return self.root
clean_path = Path(relative_path).as_posix()
if ".." in clean_path or clean_path.startswith("/"):
return None
return self.root / clean_path
[docs]
def is_file_allowed(self, rel_path: str) -> bool:
"""Check if file matches include patterns and not exclude patterns."""
return self._include_spec.match_file(
rel_path
) and not self._exclude_spec.match_file(rel_path)
[docs]
def is_append_allowed(self, rel_path: str) -> bool:
"""Check if file matches append_allowed patterns."""
return self._append_spec.match_file(rel_path)
[docs]
def get_service(context: BotContext | None, key: str) -> Any:
"""Extract a named service from context."""
if context is None:
return None
return context.services.get(key)
[docs]
def get_services(context: BotContext | None) -> FolderServices | None:
"""Extract FolderServices from context."""
return get_service(context, "folder")
[docs]
def get_root(context: BotContext | None) -> Path | None:
"""Get root folder from context services."""
services = get_services(context)
return services.root if services else None
[docs]
def validate_path(context: BotContext | None, relative_path: str) -> Path | None:
"""Validate path using context services."""
services = get_services(context)
if services is None:
return None
return services.validate_path(relative_path)
[docs]
def is_file_allowed(context: BotContext | None, rel_path: str) -> bool:
"""Check if file is allowed using context services."""
services = get_services(context)
if services is None:
return False
return services.is_file_allowed(rel_path)
[docs]
def is_append_allowed(context: BotContext | None, rel_path: str) -> bool:
"""Check if append is allowed using context services."""
services = get_services(context)
if services is None:
return False
return services.is_append_allowed(rel_path)
# Central bot instance for folder tools
folder_bot = FolderBot()
[docs]
async def execute_with_logging(
tool_name: str,
tool_input: dict[str, Any],
context: BotContext | None = None,
) -> ToolResult:
"""Execute a tool with activity logging."""
start_time = time.time()
user_id = context.user_id if context else 0
try:
result = await folder_bot.execute_tool(tool_name, tool_input, context)
except Exception as e:
result = ToolResult(content=f"Tool execution error: {e}", is_error=True)
# Log to activity logger if available
services = get_services(context)
if services and services.activity_logger:
duration_ms = int((time.time() - start_time) * 1000)
# Handle both ToolResult and other response types
if isinstance(result, ToolResult):
content = result.content
is_error = result.is_error
else:
content = (
result.model_dump_json()
if hasattr(result, "model_dump_json")
else str(result)
)
is_error = False
services.activity_logger.log_tool_call(
tool_name=tool_name,
tool_input=tool_input,
result=content,
is_error=is_error,
user_id=user_id,
duration_ms=duration_ms,
)
return result