#!/usr/bin/env python3 # args_json: {"type":"object","required":["path","old_string","new_string"],"properties":{"path":{"type":"string","description":"Absolute path to the file to edit"},"old_string":{"type":"string","description":"Text to find (fuzzy whitespace matching)"},"new_string":{"type":"string","description":"Replacement text"},"replace_all":{"type":"string","description":"Set to true/1/yes to replace all occurrences"}}} # ollie:prompt # ## file_edit # # Replace text in a file. Has fuzzy matching (whitespace/indent flexible). # # **Args**: `path`, `old_string`, `new_string` (required); `replace_all` (optional) # # ``` # file_edit(path="/abs/path", old_string="old text", new_string="new text") # file_edit(path="/abs/path", old_string="old text", new_string="new text", replace_all="true") # ``` # # **Constraints**: Absolute paths only. Errors if `old_string` matches multiple locations — add surrounding context to disambiguate, or use `replace_all=true`. # ollie:end import sys import os import difflib sys.path.insert(0, os.environ.get('OLLIE_TOOLS_PATH', os.path.dirname(os.path.abspath(__file__)))) from _lib.args import parse_args def format_diff(path, old, new): old_lines = old.splitlines(keepends=True) new_lines = new.splitlines(keepends=True) diff = ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path)) if not diff: return None return f"```diff\n{diff}```" def find_exact(content, find): return find if find in content else None def find_ws_normalized(content, find): nf = ' '.join(find.split()) find_lines = find.split('\n') n = len(find_lines) lines = content.split('\n') for i in range(len(lines) - n + 1): block = '\n'.join(lines[i:i+n]) if ' '.join(block.split()) == nf: return block return None def find_indent_flexible(content, find): def deindent(text): ls = text.split('\n') min_indent = float('inf') for l in ls: stripped = l.lstrip(' \t') if stripped: min_indent = min(min_indent, len(l) - len(stripped)) if min_indent == float('inf'): return text return '\n'.join(l[min_indent:] if len(l) > min_indent else l.lstrip(' \t') for l in ls) nf = deindent(find) find_lines = find.split('\n') n = len(find_lines) lines = content.split('\n') for i in range(len(lines) - n + 1): block = '\n'.join(lines[i:i+n]) if deindent(block) == nf: return block return None def find_trimmed_boundary(content, find): trimmed = find.strip() if trimmed == find: return None if trimmed in content: return trimmed find_lines = find.split('\n') n = len(find_lines) lines = content.split('\n') for i in range(len(lines) - n + 1): block = '\n'.join(lines[i:i+n]) if block.strip() == trimmed: return block return None def robust_replace(content, old_string, new_string, replace_all): match = (find_exact(content, old_string) or find_ws_normalized(content, old_string) or find_indent_flexible(content, old_string) or find_trimmed_boundary(content, old_string)) if match is None: return None, "old_string not found in file", False if replace_all: return content.replace(match, new_string), None, False n = content.count(match) if n > 1: return None, f"old_string is not unique ({n} occurrences); use replace_all=true or provide more context", True return content.replace(match, new_string, 1), None, False args = parse_args() file_path = args.require("path") old_string = args.require("old_string") new_string = args.require("new_string") _ra = args.get("replace_all", "") replace_all = _ra.lower() in ('true', '1', 'yes') if _ra else False if not os.path.isabs(file_path): print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") print(f"error: path must be absolute, got: {file_path}") sys.exit(1) if old_string == new_string: print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false") print("error: old_string and new_string must be different") sys.exit(1) if not os.path.exists(file_path): print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") print(f"error: file not found: {file_path}") sys.exit(1) try: with open(file_path, 'r', encoding='utf-8') as f: original = f.read() except UnicodeDecodeError: print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") print(f"error: file is not valid UTF-8: {file_path}") sys.exit(1) new_content, err, retryable = robust_replace(original, old_string, new_string, replace_all) if err: print(f"STATUS=error STATE_CHANGED=false RETRYABLE={'true' if retryable else 'false'}") print(f"error: {err}") sys.exit(1) try: with open(file_path, 'w', encoding='utf-8') as f: f.write(new_content) except OSError as e: print(f"STATUS=error STATE_CHANGED=false RETRYABLE=false") print(f"error: {e}") sys.exit(1) diff = format_diff(file_path, original, new_content) if diff is None: print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false") print("(no changes)") else: print("STATUS=done STATE_CHANGED=true") print(diff)