"""`filesystem_read` — reading, listing, searching and comparing files."""
from __future__ import annotations
import asyncio
import difflib
import re
import stat
from pathlib import Path
from .._internal.base import ToolContext, ToolResult
from .base import FilesystemBaseTool
from .helpers import (
_FIND_MAX_RESULTS,
_GREP_MAX_RESULTS,
_LIST_MAX_ENTRIES,
_READ_PAGE_BYTES,
_READ_PAGE_LINES,
_READ_WARN_BYTES,
_fmt_size,
_fmt_time,
_is_binary,
_iter_search_files,
_number_diff,
_number_lines,
)
_READ_ACTIONS = (
"read",
"list",
"find",
"find_up",
"grep",
"info",
"exists",
"diff",
)
_WRITE_ACTIONS = (
"write",
"append",
"edit",
"edit_lines",
"insert",
"mkdir",
"move",
"copy",
"delete",
)
class FilesystemReadTool(FilesystemBaseTool):
name = "filesystem_read"
description = (
"Read and search files and directories. read returns line-numbered text and "
"pages large files; use numbered=false to copy exact text into edit. Actions: "
"read, list, find, find_up, grep, info, exists, diff. "
"Details: tool_manual('filesystem_read')."
)
parameters = {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": list(_READ_ACTIONS),
"description": "Read-only operation to perform.",
},
"path": {
"type": "string",
"description": "File or directory path (~ is expanded).",
},
"offset": {
"type": "integer",
"description": "First line for read, 1-based.",
},
"limit": {
"type": "integer",
"description": "Max lines for read.",
},
"numbered": {
"type": "boolean",
"description": (
"Number lines in read output (default true). Use false to copy "
"exact text into edit."
),
},
"recursive": {
"type": "boolean",
"description": "Full recursive tree for list (default false).",
},
"pattern": {
"type": "string",
"description": "Glob for find (e.g. '*.py'), or search text for grep.",
},
"name": {
"type": "string",
"description": "Exact filename for find_up (e.g. 'pyproject.toml').",
},
"glob": {
"type": "string",
"description": "Optional filename filter for grep (e.g. '*.py').",
},
"regex": {
"type": "boolean",
"description": "Treat grep pattern as a regex (default false = literal).",
},
"case_sensitive": {
"type": "boolean",
"description": "Case-sensitive grep (default false).",
},
"context": {
"type": "integer",
"description": "Context lines around grep matches and diff hunks.",
},
"max_results": {
"type": "integer",
"description": "Max matches for grep/find (defaults are small).",
},
"include_ignored": {
"type": "boolean",
"description": (
"Also search .git, node_modules, .venv and similar directories "
"(default false)."
),
},
"other": {
"type": "string",
"description": "Second file for diff (compared with path).",
},
},
"required": ["action", "path"],
}
async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
action = params.get("action", "")
raw_path = params.get("path", "")
path = self._resolve_path(raw_path, ctx)
if (err := self._path_result(raw_path, path)) is not None:
return err
assert path is not None
try:
match action:
case "read":
return await asyncio.to_thread(self._read, path, params)
case "list":
return await asyncio.to_thread(self._list, path, params)
case "find":
return await asyncio.to_thread(self._find, path, params)
case "find_up":
return await asyncio.to_thread(self._find_up, path, params)
case "grep":
return await asyncio.to_thread(self._grep, path, params)
case "info":
return await asyncio.to_thread(self._info, path)
case "exists":
return ToolResult(
success=True, output="true" if path.exists() else "false"
)
case "diff":
return await asyncio.to_thread(self._diff, path, params, ctx)
case action if action in _WRITE_ACTIONS:
return self._redirect(action, "filesystem_write")
case "query":
return ToolResult(
success=False,
output="'query' is in file_ai: call file_ai with action='ask'.",
error="wrong_tool",
)
case "smart_edit":
return ToolResult(
success=False,
output="'smart_edit' is in file_ai: call file_ai with action='edit'.",
error="wrong_tool",
)
case _:
return ToolResult(
success=False,
output=(
f"Unknown action: {action!r}. "
f"filesystem_read actions: {', '.join(_READ_ACTIONS)}."
),
error="invalid_action",
)
except PermissionError as e:
return ToolResult(success=False, output=f"Permission denied: {e}", error=str(e))
except Exception as e:
return ToolResult(success=False, output=f"Filesystem error: {e}", error=str(e))
# ── read ────────────────────────────────────────────────────────────────
def _read(self, path: Path, params: dict) -> ToolResult:
if not path.exists():
return ToolResult(success=False, output=f"File not found: {path}", error="not_found")
if path.is_dir():
return ToolResult(
success=False,
output=f"Path is a directory. Use action='list' for {path}.",
error="is_directory",
)
if _is_binary(path):
return ToolResult(
success=False,
output=(
f"Binary file, not text: {path}. "
"Use action='info' for metadata or image_view for images."
),
error="binary_file",
)
file_size = path.stat().st_size
text = path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
total = len(lines)
offset = params.get("offset")
limit = params.get("limit")
if offset is not None and (not isinstance(offset, int) or offset < 1):
return ToolResult(
success=False, output="'offset' must be an integer >= 1.", error="invalid_offset"
)
if limit is not None and (not isinstance(limit, int) or limit < 1):
return ToolResult(
success=False, output="'limit' must be an integer >= 1.", error="invalid_limit"
)
explicit = offset is not None or limit is not None
start = max(0, (offset or 1) - 1)
if total and start >= total:
return ToolResult(
success=False,
output=(
f"offset={offset} is beyond the end of {path.name} "
f"({total} lines). Use offset between 1 and {total}."
),
error="offset_out_of_range",
)
if limit is not None:
end = min(total, start + limit)
elif explicit:
end = total
elif total > _READ_PAGE_LINES or file_size > _READ_PAGE_BYTES:
end = min(total, _READ_PAGE_LINES)
else:
end = total
paged = end < total
selected = lines[start:end]
numbered = params.get("numbered", True)
if total == 0:
header = f"[{path} | empty file | {_fmt_size(file_size)}]\n"
return ToolResult(success=True, output=header + "(empty file)")
if paged or explicit:
header = (
f"[{path} | lines {start + 1}–{end} of {total}"
f" | {_fmt_size(file_size)}]\n"
)
else:
header = f"[{path} | {total} lines | {_fmt_size(file_size)}]\n"
warn = (
f"⚠ Large file ({_fmt_size(file_size)}); showing a page.\n"
if file_size > _READ_WARN_BYTES and not explicit
else ""
)
body = (
_number_lines(selected, start + 1)
if numbered
else "\n".join(selected)
)
output = header + warn + body
if paged and numbered:
output += f"\n\n[more: call read with offset={end + 1}]"
return ToolResult(success=True, output=output)
# ── list / find / info ──────────────────────────────────────────────────
def _list(self, path: Path, params: dict) -> ToolResult:
if not path.exists():
return ToolResult(success=False, output=f"Path not found: {path}", error="not_found")
if path.is_file():
return self._info(path)
recursive = params.get("recursive", False)
raw_entries = list(path.rglob("*") if recursive else path.iterdir())
raw_entries.sort(key=lambda e: (e.is_file(), str(e).lower()))
truncated = len(raw_entries) > _LIST_MAX_ENTRIES
entries = raw_entries[:_LIST_MAX_ENTRIES]
lines = []
for entry in entries:
try:
info = entry.stat()
rel = entry.relative_to(path)
if entry.is_dir():
if not recursive:
try:
count = sum(1 for _ in entry.iterdir())
lines.append(f"d {rel}/ ({count} items)")
except PermissionError:
lines.append(f"d {rel}/")
else:
lines.append(f"d {rel}/")
else:
lines.append(
f" {rel!s:<48} {_fmt_size(info.st_size):>10} {_fmt_time(info.st_mtime)}"
)
except Exception:
lines.append(f"? {entry.name}")
note = " ⚠ truncated" if truncated else ""
header = f"[{path} | {len(entries)} entries{note}]\n"
return ToolResult(success=True, output=header + ("\n".join(lines) or "(empty directory)"))
def _find(self, path: Path, params: dict) -> ToolResult:
pattern = params.get("pattern")
if not pattern:
return ToolResult(
success=False,
output="'pattern' is required for find, e.g. '*.py'.",
error="missing_pattern",
)
if not path.exists():
return ToolResult(success=False, output=f"Path not found: {path}", error="not_found")
if not path.is_dir():
return ToolResult(
success=False,
output="find needs a directory path; use read for a single file.",
error="not_a_directory",
)
max_results = params.get("max_results", _FIND_MAX_RESULTS)
if not isinstance(max_results, int) or max_results < 1:
max_results = _FIND_MAX_RESULTS
max_results = min(max_results, _FIND_MAX_RESULTS)
matches: list[Path] = []
try:
for candidate in path.rglob(pattern):
matches.append(candidate)
if len(matches) >= max_results:
break
except Exception as e:
return ToolResult(success=False, output=f"Find error: {e}", error=str(e))
if not matches:
return ToolResult(
success=True,
output=f"No matches for '{pattern}' in {path}. Try a broader glob.",
)
matches.sort()
lines = []
for match in matches:
try:
size = _fmt_size(match.stat().st_size) if match.is_file() else "<dir>"
lines.append(f"{match.relative_to(path)} ({size})")
except Exception:
lines.append(str(match))
extra = (
f" ⚠ showing first {max_results}"
if len(matches) >= max_results
else ""
)
header = f"[{len(matches)} matches for '{pattern}' in {path}{extra}]\n"
return ToolResult(success=True, output=header + "\n".join(lines))
def _find_up(self, path: Path, params: dict) -> ToolResult:
filename = params.get("name")
if not filename:
return ToolResult(
success=False,
output="'name' is required for find_up, e.g. 'pyproject.toml'.",
error="missing_name",
)
current = path if path.is_dir() else path.parent
checked = []
while True:
target = current / filename
checked.append(str(target))
if target.exists():
return ToolResult(success=True, output=str(target))
parent = current.parent
if parent == current:
return ToolResult(
success=True, output=f"not found (searched: {', '.join(checked)})"
)
current = parent
def _info(self, path: Path) -> ToolResult:
if not path.exists():
return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
info = path.stat()
kind = "symlink" if path.is_symlink() else ("directory" if path.is_dir() else "file")
lines = [
f"path: {path}",
f"type: {kind}",
f"size: {_fmt_size(info.st_size)}",
f"modified: {_fmt_time(info.st_mtime)}",
f"created: {_fmt_time(info.st_ctime)}",
f"mode: {stat.filemode(info.st_mode)}",
]
if path.is_file():
if _is_binary(path):
lines.append("lines: (binary or unreadable)")
else:
try:
text = path.read_text(encoding="utf-8", errors="replace")
lines.append(f"lines: {len(text.splitlines())}")
except Exception:
lines.append("lines: (binary or unreadable)")
elif path.is_dir():
try:
children = list(path.iterdir())
lines.append(
f"contents: {sum(c.is_file() for c in children)} files, "
f"{sum(c.is_dir() for c in children)} dirs (top level)"
)
except Exception:
pass
return ToolResult(success=True, output="\n".join(lines))
# ── grep ────────────────────────────────────────────────────────────────
def _grep(self, path: Path, params: dict) -> ToolResult:
pattern = params.get("pattern", "").strip()
if not pattern:
return ToolResult(
success=False, output="'pattern' is required for grep.", error="missing_pattern"
)
if not path.exists():
return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
use_regex = params.get("regex", False)
case_sensitive = params.get("case_sensitive", False)
glob_filter = params.get("glob", "") or ""
context = params.get("context", 0)
if not isinstance(context, int) or context < 0:
context = 0
context = min(context, 5)
max_results = params.get("max_results", _GREP_MAX_RESULTS)
if not isinstance(max_results, int) or max_results < 1:
max_results = _GREP_MAX_RESULTS
max_results = min(max_results, 500)
include_ignored = bool(params.get("include_ignored", False))
flags = 0 if case_sensitive else re.IGNORECASE
try:
compiled = re.compile(pattern if use_regex else re.escape(pattern), flags)
except re.error as e:
return ToolResult(
success=False, output=f"Invalid regex: {e}", error="invalid_pattern"
)
matches: list[str] = []
files_searched = 0
hit_count = 0
capped = False
for fpath in _iter_search_files(path, glob_filter, include_ignored):
if hit_count >= max_results:
capped = True
break
if _is_binary(fpath):
continue
files_searched += 1
try:
text = fpath.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
lines = text.splitlines()
hits = [i for i, line in enumerate(lines) if compiled.search(line)]
if not hits:
continue
rel = fpath.relative_to(path) if path.is_dir() else fpath.name
hit_set = set(hits)
wanted: set[int] = set()
for index in hits:
if hit_count >= max_results:
capped = True
break
hit_count += 1
for around in range(max(0, index - context), min(len(lines), index + context + 1)):
wanted.add(around)
for index in sorted(wanted):
if index in hit_set:
matches.append(f"{rel}:{index + 1}: {lines[index]}")
else:
matches.append(f"{rel}-{index + 1}- {lines[index]}")
if capped:
break
if not matches:
return ToolResult(
success=True,
output=(
f"No matches for '{pattern}' in {path}. "
"Try a shorter pattern, regex=true, a different path, "
"or include_ignored=true."
),
)
note = f" ⚠ showing first {max_results}" if capped else ""
header = (
f"[{hit_count} matches for '{pattern}' in {path} "
f"({files_searched} files searched){note}]\n"
)
return ToolResult(success=True, output=header + "\n".join(matches))
# ── diff ────────────────────────────────────────────────────────────────
def _diff(self, path: Path, params: dict, ctx: ToolContext | None) -> ToolResult:
other_raw = params.get("other") or params.get("destination")
if not other_raw:
return ToolResult(
success=False,
output="'other' is required for diff (the second file to compare).",
error="missing_other",
)
other = self._resolve_path(other_raw, ctx)
if other is None:
return ToolResult(
success=False,
output=f"Access denied: '{other_raw}' is outside allowed paths.",
error="access_denied",
)
if not path.exists():
return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
if not other.exists():
return ToolResult(success=False, output=f"Not found: {other}", error="not_found")
if path.is_dir() or other.is_dir():
return ToolResult(
success=False, output="diff works on files, not directories.", error="is_directory"
)
context = params.get("context", 3)
if not isinstance(context, int) or context < 0:
context = 3
context = min(context, 20)
try:
a_lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
b_lines = other.read_text(encoding="utf-8", errors="replace").splitlines()
except Exception as e:
return ToolResult(success=False, output=f"Read error: {e}", error=str(e))
diff = list(
difflib.unified_diff(
a_lines,
b_lines,
fromfile=f"a/{path.name}",
tofile=f"b/{other.name}",
n=context,
lineterm="",
)
)
if not diff:
return ToolResult(success=True, output="Files are identical.")
return ToolResult(success=True, output="\n".join(_number_diff(diff)))