using System.ComponentModel; using System.Text.RegularExpressions; using AnchorCli.Hashline; namespace AnchorCli.Tools; /// /// Mutating file tools exposed to the LLM as AIFunctions. /// Every operation validates Hashline anchors (line:hash format) before touching the file. /// On success, returns "OK fp:{fingerprint}" so the model can verify the mutation. /// On failure, returns a descriptive ERROR string — no exceptions reach the LLM. /// internal static partial class EditTools { public static Action Log { get; set; } = Console.WriteLine; /// /// Matches accidental hashline prefixes the LLM may include in new content. /// Format: "lineNumber:hexHash|" e.g. "5:a3|" /// private static readonly Regex HashlinePrefix = MyRegex(); /// /// Strips accidental hashline prefixes from lines the LLM sends as new content. /// Logs a warning when stripping occurs. /// private static string[] SanitizeNewLines(string[] newLines) { bool anyStripped = false; var result = new string[newLines.Length]; for (int i = 0; i < newLines.Length; i++) { var match = HashlinePrefix.Match(newLines[i]); if (match.Success) { result[i] = newLines[i][match.Length..]; anyStripped = true; } else { result[i] = newLines[i]; } } if (anyStripped) { Log(" ⚠ Stripped hashline prefixes from new content (LLM included anchors in edit content)"); } return result; } [Description("Replace a range of lines. Both line number and hash in the line:hash anchor must match.")] public static string ReplaceLines( [Description("Path to the file.")] string path, [Description("First line's line:hash anchor (e.g. '5:a3').")] string startAnchor, [Description("Last line's line:hash anchor. Use same as startAnchor for a single line.")] string endAnchor, [Description("Raw source code to insert. Do NOT include 'lineNumber:hash|' prefixes.")] string[] newLines) { newLines = SanitizeNewLines(newLines); path = FileTools.ResolvePath(path); Log($"REPLACE_LINES: {path}"); Log($" Range: {startAnchor} -> {endAnchor}"); Log($" Replacing {endAnchor.Split(':')[0]}-{startAnchor.Split(':')[0]} lines with {newLines.Length} new lines"); if (!File.Exists(path)) return $"ERROR: File not found: {path}"; try { string[] lines = File.ReadAllLines(path); if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines, out int startIdx, out int endIdx, out string error)) return $"ERROR: {error}"; var result = new List(lines.Length - (endIdx - startIdx + 1) + newLines.Length); result.AddRange(lines[..startIdx]); result.AddRange(newLines); result.AddRange(lines[(endIdx + 1)..]); File.WriteAllLines(path, result); return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; } catch (Exception ex) { return $"ERROR modifying '{path}': {ex.Message}"; } } [Description("Delete a range of lines.")] public static string DeleteRange( [Description("Path to the file.")] string path, [Description("First line's line:hash anchor (e.g. '4:7c').")] string startAnchor, [Description("Last line's line:hash anchor (e.g. '6:19').")] string endAnchor) { path = FileTools.ResolvePath(path); Log($"Deleting lines in file: {path}"); if (!File.Exists(path)) return $"ERROR: File not found: {path}"; try { string[] lines = File.ReadAllLines(path); if (!HashlineValidator.TryResolveRange(startAnchor, endAnchor, lines, out int startIdx, out int endIdx, out string error)) return $"ERROR: {error}"; var result = new List(lines.Length - (endIdx - startIdx + 1)); result.AddRange(lines[..startIdx]); result.AddRange(lines[(endIdx + 1)..]); File.WriteAllLines(path, result); return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; } catch (Exception ex) { return $"ERROR modifying '{path}': {ex.Message}"; } } [Description("Delete a file or directory. Use mode='file' to delete a file, mode='dir' to delete a directory.")] public static string Delete( [Description("Path to the file or directory to delete.")] string path, [Description("Type of deletion: 'file' or 'dir'. Defaults to 'file'.")] string mode = "file") { path = FileTools.ResolvePath(path); string targetType = mode.ToLower() == "dir" ? "directory" : "file"; Log($"Deleting {targetType}: {path}"); if (mode.ToLower() == "dir") { if (!Directory.Exists(path)) return $"ERROR: Directory not found: {path}"; try { Directory.Delete(path, true); return $"OK: Directory deleted: '{path}'"; } catch (Exception ex) { return $"ERROR deleting directory '{path}': {ex.Message}"; } } else { if (!File.Exists(path)) return $"ERROR: File not found: {path}"; try { File.Delete(path); return $"OK (deleted)"; } catch (Exception ex) { return $"ERROR deleting '{path}': {ex.Message}"; } } } [Description("Move or copy a file to a new location.")] public static string MoveFile( [Description("Current path to the file.")] string sourcePath, [Description("New path for the file.")] string destinationPath, [Description("If true, copy the file instead of moving it. Defaults to false.")] bool copy = false) { sourcePath = FileTools.ResolvePath(sourcePath); destinationPath = FileTools.ResolvePath(destinationPath); string action = copy ? "Copying" : "Moving"; Log($"{action} file: {sourcePath} -> {destinationPath}"); if (!File.Exists(sourcePath)) return $"ERROR: Source file not found: {sourcePath}"; if (File.Exists(destinationPath)) return $"ERROR: Destination file already exists: {destinationPath}"; try { string? dir = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); if (copy) File.Copy(sourcePath, destinationPath); else File.Move(sourcePath, destinationPath); return copy ? $"OK (copied to {destinationPath})" : $"OK (moved to {destinationPath})"; } catch (Exception ex) { return $"ERROR {action.ToLower()} file: {ex.Message}"; } } [Description("Write to a file with different modes: create, append, or insert.")] public static string WriteToFile( [Description("Path to the file.")] string path, [Description("Content to write.")] string[] content, [Description("Write mode: 'create' (error if exists), 'append' (creates if missing), 'insert' (requires anchor)")] string mode = "create", [Description("line:hash anchor to insert after (required for mode='insert', e.g. '3:0e').")] string? anchor = null) { content = SanitizeNewLines(content); path = FileTools.ResolvePath(path); Log($"WRITE_TO_FILE: {path}"); Log($" Mode: {mode}"); Log($" Writing {content.Length} lines"); try { string? dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); switch (mode.ToLower()) { case "create": if (File.Exists(path)) return $"ERROR: File already exists: {path}"; if (content.Length > 0) File.WriteAllLines(path, content); else File.WriteAllText(path, ""); return $"OK fp:{HashlineEncoder.FileFingerprint(content)}"; case "append": if (!File.Exists(path)) { File.WriteAllText(path, ""); Log($" (created new file)"); } using (var writer = new System.IO.StreamWriter(path, true)) { foreach (var line in content) { writer.WriteLine(line); } } string[] appendedLines = File.ReadAllLines(path); return $"OK fp:{HashlineEncoder.FileFingerprint([.. appendedLines])}"; case "insert": if (!File.Exists(path)) return $"ERROR: File not found: {path}"; if (string.IsNullOrEmpty(anchor)) return "ERROR: mode='insert' requires an anchor parameter"; string[] lines = File.ReadAllLines(path); if (!HashlineValidator.TryResolve(anchor, lines, out int idx, out string error)) return $"ERROR: {error}"; var result = new List(lines.Length + content.Length); result.AddRange(lines[..(idx + 1)]); result.AddRange(content); result.AddRange(lines[(idx + 1)..]); File.WriteAllLines(path, result); return $"OK fp:{HashlineEncoder.FileFingerprint([.. result])}"; default: return $"ERROR: Unknown mode '{mode}'. Valid modes: create, append, insert"; } } catch (Exception ex) { return $"ERROR writing to '{path}': {ex.Message}"; } } [GeneratedRegex(@"^\d+:[0-9a-fA-F]{2}\|", RegexOptions.Compiled)] private static partial Regex MyRegex(); }