diff --git a/.opencode/agents/general.md b/.opencode/agents/general.md index 4662171..9e15e60 100644 --- a/.opencode/agents/general.md +++ b/.opencode/agents/general.md @@ -28,9 +28,11 @@ You MUST use Manual Slop's MCP tools. Native OpenCode tools are unreliable. ### Edit MCP Tools (USE THESE) | Native Tool | MCP Tool | |-------------|----------| +| `edit` | `manual-slop_edit_file` (find/replace, preserves indentation) | | `edit` | `manual-slop_py_update_definition` (replace function/class) | | `edit` | `manual-slop_set_file_slice` (replace line range) | | `edit` | `manual-slop_py_set_signature` (replace signature only) | +| `edit` | `manual-slop_py_set_var_declaration` (replace variable) | ### Shell Commands | Native Tool | MCP Tool | diff --git a/.opencode/agents/tier2-tech-lead.md b/.opencode/agents/tier2-tech-lead.md index 8436772..1ed05a8 100644 --- a/.opencode/agents/tier2-tech-lead.md +++ b/.opencode/agents/tier2-tech-lead.md @@ -34,6 +34,7 @@ You MUST use Manual Slop's MCP tools. Native OpenCode tools are unreliable. ### Edit MCP Tools (USE THESE) | Native Tool | MCP Tool | |-------------|----------| +| `edit` | `manual-slop_edit_file` (find/replace, preserves indentation) | | `edit` | `manual-slop_py_update_definition` (replace function/class) | | `edit` | `manual-slop_set_file_slice` (replace line range) | | `edit` | `manual-slop_py_set_signature` (replace signature only) | diff --git a/.opencode/agents/tier3-worker.md b/.opencode/agents/tier3-worker.md index 256128a..4be45f4 100644 --- a/.opencode/agents/tier3-worker.md +++ b/.opencode/agents/tier3-worker.md @@ -32,6 +32,7 @@ You MUST use Manual Slop's MCP tools. Native OpenCode tools are unreliable. ### Edit MCP Tools (USE THESE - BAN NATIVE EDIT) | Native Tool | MCP Tool | |-------------|----------| +| `edit` | `manual-slop_edit_file` (find/replace, preserves indentation) | | `edit` | `manual-slop_py_update_definition` (replace function/class) | | `edit` | `manual-slop_set_file_slice` (replace line range) | | `edit` | `manual-slop_py_set_signature` (replace signature only) | diff --git a/src/mcp_client.py b/src/mcp_client.py index 2d1a5f2..3516429 100644 --- a/src/mcp_client.py +++ b/src/mcp_client.py @@ -51,6 +51,7 @@ MUTATING_TOOLS: frozenset[str] = frozenset({ "py_update_definition", "py_set_signature", "py_set_var_declaration", + "edit_file", }) # ------------------------------------------------------------------ state @@ -315,6 +316,37 @@ def set_file_slice(path: str, start_line: int, end_line: int, new_content: str) except Exception as e: return f"ERROR updating slice in '{path}': {e}" +def edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False) -> str: + """ + Replace exact string match in a file. Preserves indentation and line endings. + Drop-in replacement for native edit tool that destroys 1-space indentation. + """ + p, err = _resolve_and_check(path) + if err: + return err + assert p is not None + if not p.exists(): + return f"ERROR: file not found: {path}" + if not old_string: + return "ERROR: old_string cannot be empty" + try: + content = p.read_text(encoding="utf-8", newline="") + if old_string not in content: + return f"ERROR: old_string not found in '{path}'" + count = content.count(old_string) + if count > 1 and not replace_all: + return f"ERROR: Found {count} matches for old_string in '{path}'. Use replace_all=true or provide more context to make it unique." + if replace_all: + new_content = content.replace(old_string, new_string) + p.write_text(new_content, encoding="utf-8", newline="") + return f"Successfully replaced {count} occurrences in '{path}'" + else: + new_content = content.replace(old_string, new_string, 1) + p.write_text(new_content, encoding="utf-8", newline="") + return f"Successfully replaced 1 occurrence in '{path}'" + except Exception as e: + return f"ERROR editing '{path}': {e}" + def _get_symbol_node(tree: ast.AST, name: str) -> Optional[ast.AST]: """Helper to find an AST node by name (Class, Function, or Variable). Supports dot notation.""" parts = name.split(".") @@ -824,7 +856,7 @@ def get_ui_performance() -> str: return f"ERROR: Failed to retrieve UI performance: {str(e)}" # ------------------------------------------------------------------ tool dispatch -TOOL_NAMES: set[str] = {"read_file", "list_directory", "search_files", "get_file_summary", "py_get_skeleton", "py_get_code_outline", "py_get_definition", "get_git_diff", "web_search", "fetch_url", "get_ui_performance", "get_file_slice", "set_file_slice", "py_update_definition", "py_get_signature", "py_set_signature", "py_get_class_summary", "py_get_var_declaration", "py_set_var_declaration", "py_find_usages", "py_get_imports", "py_check_syntax", "py_get_hierarchy", "py_get_docstring", "get_tree"} +TOOL_NAMES: set[str] = {"read_file", "list_directory", "search_files", "get_file_summary", "py_get_skeleton", "py_get_code_outline", "py_get_definition", "get_git_diff", "web_search", "fetch_url", "get_ui_performance", "get_file_slice", "set_file_slice", "edit_file", "py_update_definition", "py_get_signature", "py_set_signature", "py_get_class_summary", "py_get_var_declaration", "py_set_var_declaration", "py_find_usages", "py_get_imports", "py_check_syntax", "py_get_hierarchy", "py_get_docstring", "get_tree"} def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str: """ @@ -868,6 +900,13 @@ def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str: str(tool_input.get("base_rev", "HEAD")), str(tool_input.get("head_rev", "")) ) + if tool_name == "edit_file": + return edit_file( + path, + str(tool_input.get("old_string", "")), + str(tool_input.get("new_string", "")), + bool(tool_input.get("replace_all", False)) + ) if tool_name == "web_search": return web_search(str(tool_input.get("query", ""))) if tool_name == "fetch_url": @@ -1048,7 +1087,33 @@ MCP_TOOL_SPECS: list[dict[str, Any]] = [ "description": "New content to insert." } }, - "required": ["path", "start_line", "end_line", "new_content"] + "required": ["path", "start_line", "end_line", "new_content"] + } + }, + { + "name": "edit_file", + "description": "Replace exact string match in a file. Preserves indentation and line endings. Drop-in replacement for native edit tool.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file." + }, + "old_string": { + "type": "string", + "description": "The text to replace." + }, + "new_string": { + "type": "string", + "description": "The replacement text." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all occurrences. Default false." + } + }, + "required": ["path", "old_string", "new_string"] } }, {