"""`filesystem_write` — creating, editing and managing files."""
from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
from .._internal.base import ToolContext, ToolResult
from .base import FilesystemBaseTool
from .helpers import (
_apply_ops,
_atomic_write,
_context_around,
_fmt_size,
_looks_numbered,
_nearest_lines,
_unified_diff,
_validate_ops,
)
_WRITE_ACTIONS = (
"write",
"append",
"edit",
"edit_lines",
"insert",
"mkdir",
"move",
"copy",
"delete",
)
_READ_ACTIONS = (
"read",
"list",
"find",
"find_up",
"grep",
"info",
"exists",
"diff",
)
class FilesystemWriteTool(FilesystemBaseTool):
name = "filesystem_write"
description = (
"Create and change files. edit replaces exact text that must occur once; "
"edit_lines changes 1-based line ranges; insert adds lines; write rewrites a "
"whole file. Actions: write, append, edit, edit_lines, insert, mkdir, move, "
"copy, delete. Prefer edit/edit_lines over rewriting. "
"Details: tool_manual('filesystem_write')."
)
parameters = {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": list(_WRITE_ACTIONS),
"description": "Write operation to perform.",
},
"path": {
"type": "string",
"description": "File or directory path (~ is expanded).",
},
"content": {
"type": "string",
"description": "Text for write, append or insert.",
},
"old": {
"type": "string",
"description": "Exact text to replace for edit; must occur exactly once.",
},
"new": {
"type": "string",
"description": "Replacement text for edit.",
},
"replace_all": {
"type": "boolean",
"description": "Replace every occurrence of old (default false).",
},
"operations": {
"type": "array",
"description": (
"Line edits for edit_lines: 1-based inclusive. insert uses 'after' "
"(0 means before line 1)."
),
"items": {
"type": "object",
"properties": {
"op": {"type": "string", "enum": ["replace", "delete", "insert"]},
"start": {"type": "integer"},
"end": {"type": "integer"},
"after": {"type": "integer"},
"content": {"type": "string"},
},
"required": ["op"],
},
},
"after": {
"type": "integer",
"description": "Line number after which insert adds content (0 = before line 1).",
},
"destination": {
"type": "string",
"description": "Target path for move or copy.",
},
"overwrite": {
"type": "boolean",
"description": "Allow move/copy to replace an existing destination.",
},
"recursive": {
"type": "boolean",
"description": "Allow delete to remove a non-empty directory.",
},
"force": {
"type": "boolean",
"description": "Allow write with empty content over an existing file.",
},
},
"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 "write":
return await asyncio.to_thread(self._write, path, params)
case "append":
return await asyncio.to_thread(self._append, path, params)
case "edit":
return await asyncio.to_thread(self._edit, path, params)
case "edit_lines":
return await asyncio.to_thread(self._edit_lines, path, params)
case "insert":
return await asyncio.to_thread(self._insert, path, params)
case "mkdir":
return await asyncio.to_thread(self._mkdir, path)
case "move":
return await asyncio.to_thread(self._move, path, params, ctx)
case "copy":
return await asyncio.to_thread(self._copy, path, params, ctx)
case "delete":
return await asyncio.to_thread(self._delete, path, params)
case action if action in _READ_ACTIONS:
return self._redirect(action, "filesystem_read")
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_write actions: {', '.join(_WRITE_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))
# ── write / append ──────────────────────────────────────────────────────
def _write(self, path: Path, params: dict) -> ToolResult:
content = params.get("content")
if content is None:
return ToolResult(
success=False, output="'content' is required for write.", error="missing_content"
)
if path.exists() and path.is_dir():
return ToolResult(
success=False, output="write works on files, not directories.", error="is_directory"
)
old_text: str | None = None
old_lines: int | None = None
if path.exists():
old_text = path.read_text(encoding="utf-8", errors="replace")
old_lines = len(old_text.splitlines())
if content == "" and not params.get("force", False):
return ToolResult(
success=False,
output=(
f"Refusing to overwrite {path} with empty content. "
"Pass force=true if this is intentional."
),
error="empty_overwrite",
)
path.parent.mkdir(parents=True, exist_ok=True)
_atomic_write(path, content)
new_lines = len(content.splitlines())
if old_lines is None:
output = f"Wrote {_fmt_size(len(content.encode()))} ({new_lines} lines) → {path}"
return ToolResult(success=True, output=output)
output = (
f"Overwrote {path}: {old_lines} → {new_lines} lines "
f"({_fmt_size(len(content.encode()))})."
)
if old_lines and new_lines < old_lines * 0.5:
output += (
"\n⚠ New content is much shorter than the old file. "
"If this was a partial edit, use edit or edit_lines instead."
)
metadata = {}
if old_text is not None and len(old_text) < 1_000_000 and len(content) < 1_000_000:
diff = _unified_diff(old_text.splitlines(), content.splitlines(), path)
if diff:
metadata["diff"] = diff
return ToolResult(success=True, output=output, metadata=metadata)
def _append(self, path: Path, params: dict) -> ToolResult:
content = params.get("content")
if not content:
return ToolResult(
success=False,
output="'content' is required for append and must not be empty.",
error="missing_content",
)
if path.exists() and path.is_dir():
return ToolResult(
success=False, output="append works on files, not directories.", error="is_directory"
)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(content)
return ToolResult(
success=True,
output=(
f"Appended {_fmt_size(len(content.encode()))} to {path} "
f"(file now {_fmt_size(path.stat().st_size)})"
),
)
# ── edit ────────────────────────────────────────────────────────────────
def _edit(self, path: Path, params: dict) -> ToolResult:
old = params.get("old")
new = params.get("new")
if old is None:
return ToolResult(
success=False, output="'old' is required for edit.", error="missing_old"
)
if new is None:
return ToolResult(
success=False, output="'new' is required for edit.", error="missing_new"
)
if old == "":
return ToolResult(success=False, output="'old' must not be empty.", error="empty_old")
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="edit works on files, not directories.", error="is_directory"
)
text = path.read_text(encoding="utf-8", errors="replace")
count = text.count(old)
replace_all = bool(params.get("replace_all", False))
if count == 0:
return self._old_not_found(path, text, old)
if count > 1 and not replace_all:
positions: list[int] = []
start = 0
while len(positions) < 20:
index = text.find(old, start)
if index < 0:
break
positions.append(text.count("\n", 0, index) + 1)
start = index + max(1, len(old))
where = ", ".join(str(n) for n in positions)
return ToolResult(
success=False,
output=(
f"Exact text occurs {count} times in {path} at lines: {where}.\n"
"Include more surrounding lines so it matches once, use edit_lines with "
"line numbers, or set replace_all=true to replace every occurrence."
),
error="old_not_unique",
)
first_line = text.count("\n", 0, text.find(old)) + 1
new_text = text.replace(old, new, -1 if replace_all else 1)
_atomic_write(path, new_text)
old_lines = text.splitlines()
new_lines = new_text.splitlines()
replaced = count if replace_all else 1
summary = (
f"Edited {path}: replaced {replaced} occurrence(s), "
f"{len(old.encode())} B → {len(new.encode())} B."
)
context = _context_around(new_lines, [first_line], radius=2)
output = f"{summary}\n\n{context}" if context else summary
diff = _unified_diff(old_lines, new_lines, path)
return ToolResult(
success=True,
output=output,
metadata={"diff": diff} if diff else {},
)
def _old_not_found(self, path: Path, text: str, old: str) -> ToolResult:
parts = [f"Exact text not found in {path}."]
if _looks_numbered(old):
parts.append(
"The 'old' text looks like it includes the line-number column from read. "
"Read with numbered=false and copy only the file text."
)
candidates = _nearest_lines(text, old)
if candidates:
parts.append("Closest lines:")
parts.extend(f" {number}: {line}" for number, line in candidates)
parts.append(
"Read the relevant section and call edit again with text copied exactly "
"from the file, including indentation."
)
return ToolResult(success=False, output="\n".join(parts), error="old_not_found")
def _edit_lines(self, path: Path, params: dict) -> ToolResult:
ops = params.get("operations")
if not ops:
return ToolResult(
success=False,
output="'operations' is required for edit_lines.",
error="missing_operations",
)
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="edit_lines works on files, not directories.",
error="is_directory",
)
text = path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
errors = _validate_ops(ops, len(lines))
if errors:
return ToolResult(
success=False,
output=(
f"Invalid operations for {path.name} ({len(lines)} lines):\n"
+ "\n".join(f" • {e}" for e in errors)
+ "\nLines are 1-based and inclusive."
),
error="invalid_ops",
)
new_lines = _apply_ops(lines, ops)
diff = _unified_diff(lines, new_lines, path)
new_text = "\n".join(new_lines) + ("\n" if text.endswith("\n") else "")
_atomic_write(path, new_text)
summary = (
f"Applied {len(ops)} operation(s) to {path.name} "
f"({len(lines)} → {len(new_lines)} lines)."
)
return ToolResult(success=True, output=f"{summary}\n\n{diff}" if diff else summary)
def _insert(self, path: Path, params: dict) -> ToolResult:
after = params.get("after")
content = params.get("content")
if not isinstance(after, int) or after < 0:
return ToolResult(
success=False,
output="'after' must be an integer >= 0 (0 = before line 1).",
error="missing_after",
)
if content is None:
return ToolResult(
success=False, output="'content' is required for insert.", error="missing_content"
)
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="insert works on files, not directories.", error="is_directory"
)
text = path.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
if after > len(lines):
return ToolResult(
success=False,
output=f"'after'={after} is beyond the file (0–{len(lines)}).",
error="invalid_after",
)
inserted = content.split("\n")
new_lines = lines[:after] + inserted + lines[after:]
new_text = "\n".join(new_lines) + ("\n" if text.endswith("\n") else "")
_atomic_write(path, new_text)
diff = _unified_diff(lines, new_lines, path)
summary = (
f"Inserted {len(inserted)} line(s) after line {after} in {path.name} "
f"({len(lines)} → {len(new_lines)} lines)."
)
return ToolResult(success=True, output=f"{summary}\n\n{diff}" if diff else summary)
# ── directories and paths ───────────────────────────────────────────────
def _mkdir(self, path: Path) -> ToolResult:
if path.exists() and not path.is_dir():
return ToolResult(
success=False,
output=f"Path exists and is not a directory: {path}",
error="not_a_directory",
)
path.mkdir(parents=True, exist_ok=True)
return ToolResult(success=True, output=f"Directory created: {path}")
def _move(self, path: Path, params: dict, ctx: ToolContext | None) -> ToolResult:
dest_raw = params.get("destination")
if not dest_raw:
return ToolResult(
success=False,
output="'destination' is required for move.",
error="missing_destination",
)
dest = self._resolve_path(dest_raw, ctx)
if dest is None:
return ToolResult(
success=False,
output=f"Access denied: destination '{dest_raw}' outside allowed paths.",
error="access_denied",
)
if not path.exists():
return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
overwrite = bool(params.get("overwrite", False))
if dest.exists() and not overwrite:
return ToolResult(
success=False,
output=(
f"Destination already exists: {dest}. "
"Pass overwrite=true to replace it."
),
error="destination_exists",
)
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
if dest.is_dir():
shutil.rmtree(dest)
else:
dest.unlink()
shutil.move(str(path), str(dest))
return ToolResult(success=True, output=f"Moved: {path} → {dest}")
def _copy(self, path: Path, params: dict, ctx: ToolContext | None) -> ToolResult:
dest_raw = params.get("destination")
if not dest_raw:
return ToolResult(
success=False,
output="'destination' is required for copy.",
error="missing_destination",
)
dest = self._resolve_path(dest_raw, ctx)
if dest is None:
return ToolResult(
success=False,
output=f"Access denied: destination '{dest_raw}' outside allowed paths.",
error="access_denied",
)
if not path.exists():
return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
overwrite = bool(params.get("overwrite", False))
if dest.exists() and not overwrite:
return ToolResult(
success=False,
output=(
f"Destination already exists: {dest}. "
"Pass overwrite=true to replace it."
),
error="destination_exists",
)
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists() and overwrite:
if dest.is_dir():
shutil.rmtree(dest)
else:
dest.unlink()
if path.is_dir():
shutil.copytree(str(path), str(dest), dirs_exist_ok=overwrite)
else:
shutil.copy2(str(path), str(dest))
return ToolResult(success=True, output=f"Copied: {path} → {dest}")
def _delete(self, path: Path, params: dict) -> ToolResult:
if not path.exists():
return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
if path.is_dir():
children = list(path.iterdir())
if children and not params.get("recursive", False):
return ToolResult(
success=False,
output=(
f"{path} is a non-empty directory ({len(children)} entries). "
"Pass recursive=true to delete the whole tree."
),
error="directory_not_empty",
)
if children:
shutil.rmtree(path)
else:
path.rmdir()
return ToolResult(success=True, output=f"Deleted directory: {path}")
path.unlink()
return ToolResult(success=True, output=f"Deleted: {path}")