James Lindsay
feat: add write_file and edit_file tools (#14)
5ea571a unverified
Raw
History Blame Contribute Delete
3.55 kB
from pathlib import Path
from cli_textual.tools._editblock import replace_most_similar_chunk
from cli_textual.tools.base import ToolResult
from cli_textual.tools.write_file import _resolve_in_workspace
async def edit_file(
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
workspace_root: Path | None = None,
) -> ToolResult:
"""Replace ``old_string`` with ``new_string`` in a file.
Interface mirrors opencode's ``edit`` tool; matching is forgiving (ported
from Aider) so edits survive whitespace drift and ``...`` elisions instead
of failing on a byte-for-byte mismatch.
- Errors if ``old_string`` is not found.
- Errors if ``old_string`` matches multiple times, unless ``replace_all``.
- Pass ``new_string=""`` to delete the matched text.
- Path is jailed to the workspace directory.
Args:
path: File path (relative to the workspace or absolute).
old_string: Exact text to find (include enough context to be unique).
new_string: Replacement text.
replace_all: Replace every occurrence instead of requiring a unique match.
"""
try:
_, file_path = _resolve_in_workspace(path, workspace_root)
except PermissionError as exc:
return ToolResult(output=f"Error: {exc}", is_error=True)
if old_string == "":
return ToolResult(
output="Error: old_string must not be empty (use write_file to create/replace a file)",
is_error=True,
)
if not file_path.exists():
return ToolResult(output=f"Error: file not found: {path}", is_error=True)
try:
# Strict decode: silently round-tripping a non-UTF-8 file with
# errors="replace" would corrupt its bytes on write.
content = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return ToolResult(output=f"Error: {path} is not valid UTF-8 text", is_error=True)
except Exception as exc:
return ToolResult(output=f"Error reading file: {exc}", is_error=True)
if old_string == new_string:
return ToolResult(output="Error: old_string and new_string are identical", is_error=True)
count = content.count(old_string)
if replace_all:
if count == 0:
return ToolResult(output="Error: old_string not found", is_error=True)
new_content = content.replace(old_string, new_string)
replaced = count
elif count == 1:
new_content = content.replace(old_string, new_string, 1)
replaced = 1
elif count > 1:
return ToolResult(
output=(
f"Error: found {count} matches for old_string. Provide more "
"surrounding context to identify a unique match, or set replace_all=True."
),
is_error=True,
)
else:
# No exact match — fall back to Aider's forgiving matcher (whitespace
# drift, dropped blank line, ... elision). Replaces a single chunk.
new_content = replace_most_similar_chunk(content, old_string, new_string)
if new_content is None:
return ToolResult(output="Error: old_string not found", is_error=True)
replaced = 1
try:
file_path.write_text(new_content, encoding="utf-8")
except Exception as exc:
return ToolResult(output=f"Error writing file: {exc}", is_error=True)
suffix = " (all occurrences)" if replace_all and replaced > 1 else ""
return ToolResult(output=f"Edited {path}: {replaced} replacement(s){suffix}")