#!/usr/bin/env python3 """str_replace_editor — CLI-as-MCP wrapper around the vendored EditTool. This is the "CLI-as-MCP" delivery of the `str_replace_editor` tool: the agent (which has ONLY the bash tool) invokes this script and passes the tool's arguments as one JSON object on stdin. The actual editing logic is the vendored `EditTool` under str_replace_editor_vendor/ (see VENDORED.md) — we add no behavior, we only: * instantiate it with run_command_preexec_fn=None (the class's own documented way to skip its uid/gid-1000 demotion, which would break writes in our sandbox where the workspace is owned by the agent user); and * adapt structured stdin-JSON <-> a bash-invokable CLI. stdin: one JSON object, e.g. {"command":"view","path":"/workspace/app/models/x.rb"} {"command":"view","path":"/workspace/x.rb","view_range":[1,40]} {"command":"str_replace","path":"/workspace/x.rb","old_str":"a","new_str":"b"} {"command":"create","path":"/workspace/new.rb","file_text":"..."} {"command":"insert","path":"/workspace/x.rb","insert_line":10,"insert_text":"..."} stdout: the tool's result text (exit 0). stderr + exit 1: a tool error message. """ import asyncio import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from str_replace_editor_vendor.base import ToolError # noqa: E402 from str_replace_editor_vendor.edit import EditTool # noqa: E402 # The keyword-only params the vendored EditTool.__call__ accepts. _ACCEPTED = { "command", "path", "file_text", "view_range", "old_str", "new_str", "insert_text", "insert_line", } async def _run(payload: dict): # Reject unknown keys instead of silently dropping them: a typo like # `old_string` (vs `old_str`) should be a clear argument error, not a # confusing failure deeper inside EditTool with the param silently missing. unknown = set(payload) - _ACCEPTED if unknown: raise ToolError( f"unknown argument(s): {', '.join(sorted(unknown))}. " f"accepted keys: {', '.join(sorted(_ACCEPTED))}." ) kwargs = dict(payload) if "command" not in kwargs or "path" not in kwargs: raise ToolError("Both `command` and `path` are required.") # run_command_preexec_fn=None → no uid/gid demotion (see module docstring). tool = EditTool(run_command_preexec_fn=None) return await tool(**kwargs) def main() -> int: raw = sys.stdin.read() if not raw.strip(): sys.stderr.write("str_replace_editor: expected a JSON object on stdin\n") return 2 try: payload = json.loads(raw) except json.JSONDecodeError as e: sys.stderr.write(f"str_replace_editor: invalid JSON on stdin: {e}\n") return 2 if not isinstance(payload, dict): sys.stderr.write("str_replace_editor: stdin JSON must be an object\n") return 2 try: result = asyncio.run(_run(payload)) except ToolError as e: sys.stderr.write((e.message or "tool error") + "\n") return 1 except TypeError as e: # e.g. an unexpected/duplicate kwarg shape — surface like a tool error. sys.stderr.write(f"str_replace_editor: bad arguments: {e}\n") return 1 # EditTool returns a (CLI)Result with .output / .error / .base64_image / .system if getattr(result, "error", None): sys.stderr.write(result.error if result.error.endswith("\n") else result.error + "\n") if getattr(result, "system", None): sys.stderr.write(f"[system] {result.system}\n") out = getattr(result, "output", None) or "" if getattr(result, "base64_image", None): out += "\n(image content omitted in CLI mode)" if out: sys.stdout.write(out if out.endswith("\n") else out + "\n") return 1 if getattr(result, "error", None) else 0 if __name__ == "__main__": sys.exit(main())