Initial commit
diff --git a/claudetool/patch.go b/claudetool/patch.go
new file mode 100644
index 0000000..9254319
--- /dev/null
+++ b/claudetool/patch.go
@@ -0,0 +1,307 @@
+package claudetool
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"go/parser"
+	"go/token"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"sketch.dev/ant"
+	"sketch.dev/claudetool/editbuf"
+	"sketch.dev/claudetool/patchkit"
+)
+
+// Patch is a tool for precise text modifications in files.
+var Patch = &ant.Tool{
+	Name:        PatchName,
+	Description: strings.TrimSpace(PatchDescription),
+	InputSchema: ant.MustSchema(PatchInputSchema),
+	Run:         PatchRun,
+}
+
+const (
+	PatchName        = "patch"
+	PatchDescription = `
+File modification tool for precise text edits.
+
+Operations:
+- replace: Substitute text with new content
+- append_eof: Append new text at the end of the file
+- prepend_bof: Insert new text at the beginning of the file
+- overwrite: Replace the entire file with new content (automatically creates the file)
+
+Usage notes:
+- All inputs are interpreted literally (no automatic newline or whitespace handling)
+- For replace operations, oldText must appear EXACTLY ONCE in the file
+`
+
+	// If you modify this, update the termui template for prettier rendering.
+	PatchInputSchema = `
+{
+  "type": "object",
+  "required": ["path", "patches"],
+  "properties": {
+    "path": {
+      "type": "string",
+      "description": "Absolute path to the file to patch"
+    },
+    "patches": {
+      "type": "array",
+      "description": "List of patch requests to apply",
+      "items": {
+        "type": "object",
+        "required": ["operation", "newText"],
+        "properties": {
+          "operation": {
+            "type": "string",
+            "enum": ["replace", "append_eof", "prepend_bof", "overwrite"],
+            "description": "Type of operation to perform"
+          },
+          "oldText": {
+            "type": "string",
+            "description": "Text to locate for the operation (must be unique in file, required for replace)"
+          },
+          "newText": {
+            "type": "string",
+            "description": "The new text to use (empty for deletions)"
+          }
+        }
+      }
+    }
+  }
+}
+`
+)
+
+// TODO: maybe rename PatchRequest to PatchOperation or PatchSpec or PatchPart or just Patch?
+
+type patchInput struct {
+	Path    string         `json:"path"`
+	Patches []patchRequest `json:"patches"`
+}
+
+type patchRequest struct {
+	Operation string `json:"operation"`
+	OldText   string `json:"oldText,omitempty"`
+	NewText   string `json:"newText,omitempty"`
+}
+
+// PatchRun is the entry point for the user_patch tool.
+func PatchRun(ctx context.Context, m json.RawMessage) (string, error) {
+	var input patchInput
+	if err := json.Unmarshal(m, &input); err != nil {
+		return "", fmt.Errorf("failed to unmarshal user_patch input: %w", err)
+	}
+
+	// Validate the input
+	if !filepath.IsAbs(input.Path) {
+		return "", fmt.Errorf("path %q is not absolute", input.Path)
+	}
+	if len(input.Patches) == 0 {
+		return "", fmt.Errorf("no patches provided")
+	}
+	// TODO: check whether the file is autogenerated, and if so, require a "force" flag to modify it.
+
+	orig, err := os.ReadFile(input.Path)
+	// If the file doesn't exist, we can still apply patches
+	// that don't require finding existing text.
+	switch {
+	case errors.Is(err, os.ErrNotExist):
+		for _, patch := range input.Patches {
+			switch patch.Operation {
+			case "prepend_bof", "append_eof", "overwrite":
+			default:
+				return "", fmt.Errorf("file %q does not exist", input.Path)
+			}
+		}
+	case err != nil:
+		return "", fmt.Errorf("failed to read file %q: %w", input.Path, err)
+	}
+
+	likelyGoFile := strings.HasSuffix(input.Path, ".go")
+
+	autogenerated := likelyGoFile && isAutogeneratedGoFile(orig)
+	parsed := likelyGoFile && parseGo(orig) != nil
+
+	origStr := string(orig)
+	// Process the patches "simultaneously", minimizing them along the way.
+	// Claude generates patches that interact with each other.
+	buf := editbuf.NewBuffer(orig)
+
+	// TODO: is it better to apply the patches that apply cleanly and report on the failures?
+	// or instead have it be all-or-nothing?
+	// For now, it is all-or-nothing.
+	// TODO: when the model gets into a "cannot apply patch" cycle of doom, how do we get it unstuck?
+	// Also: how do we detect that it's in a cycle?
+	var patchErr error
+	for i, patch := range input.Patches {
+		switch patch.Operation {
+		case "prepend_bof":
+			buf.Insert(0, patch.NewText)
+		case "append_eof":
+			buf.Insert(len(orig), patch.NewText)
+		case "overwrite":
+			buf.Replace(0, len(orig), patch.NewText)
+		case "replace":
+			if patch.OldText == "" {
+				return "", fmt.Errorf("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
+			}
+
+			// Attempt to apply the patch.
+			spec, count := patchkit.Unique(origStr, patch.OldText, patch.NewText)
+			switch count {
+			case 0:
+				// no matches, maybe recoverable, continued below
+			case 1:
+				// exact match, apply
+				slog.DebugContext(ctx, "patch_applied", "method", "unique")
+				spec.ApplyToEditBuf(buf)
+				continue
+			case 2:
+				// multiple matches
+				patchErr = errors.Join(patchErr, fmt.Errorf("old text not unique:\n%s", patch.OldText))
+			default:
+				// TODO: return an error instead of using agentPatch
+				slog.ErrorContext(ctx, "unique returned unexpected count", "count", count)
+				patchErr = errors.Join(patchErr, fmt.Errorf("internal error"))
+				continue
+			}
+
+			// The following recovery mechanisms are heuristic.
+			// They aren't perfect, but they appear safe,
+			// and the cases they cover appear with some regularity.
+
+			// Try adjusting the whitespace prefix.
+			spec, ok := patchkit.UniqueDedent(origStr, patch.OldText, patch.NewText)
+			if ok {
+				slog.DebugContext(ctx, "patch_applied", "method", "unique_dedent")
+				spec.ApplyToEditBuf(buf)
+				continue
+			}
+
+			// Try ignoring leading/trailing whitespace in a semantically safe way.
+			spec, ok = patchkit.UniqueInValidGo(origStr, patch.OldText, patch.NewText)
+			if ok {
+				slog.DebugContext(ctx, "patch_applied", "method", "unique_in_valid_go")
+				spec.ApplyToEditBuf(buf)
+				continue
+			}
+
+			// Try ignoring semantically insignificant whitespace.
+			spec, ok = patchkit.UniqueGoTokens(origStr, patch.OldText, patch.NewText)
+			if ok {
+				slog.DebugContext(ctx, "patch_applied", "method", "unique_go_tokens")
+				spec.ApplyToEditBuf(buf)
+				continue
+			}
+
+			// Try trimming the first line of the patch, if we can do so safely.
+			spec, ok = patchkit.UniqueTrim(origStr, patch.OldText, patch.NewText)
+			if ok {
+				slog.DebugContext(ctx, "patch_applied", "method", "unique_trim")
+				spec.ApplyToEditBuf(buf)
+				continue
+			}
+
+			// No dice.
+			patchErr = errors.Join(patchErr, fmt.Errorf("old text not found:\n%s", patch.OldText))
+			continue
+		default:
+			return "", fmt.Errorf("unrecognized operation %q", patch.Operation)
+		}
+	}
+
+	if patchErr != nil {
+		sendTelemetry(ctx, "patch_error", map[string]any{
+			"orig":    origStr,
+			"patches": input.Patches,
+			"errors":  patchErr,
+		})
+		return "", patchErr
+	}
+
+	patched, err := buf.Bytes()
+	if err != nil {
+		return "", err
+	}
+	if err := os.MkdirAll(filepath.Dir(input.Path), 0o700); err != nil {
+		return "", fmt.Errorf("failed to create directory %q: %w", filepath.Dir(input.Path), err)
+	}
+	if err := os.WriteFile(input.Path, patched, 0o600); err != nil {
+		return "", fmt.Errorf("failed to write patched contents to file %q: %w", input.Path, err)
+	}
+
+	response := new(strings.Builder)
+	fmt.Fprintf(response, "- Applied all patches\n")
+
+	if parsed {
+		parseErr := parseGo(patched)
+		if parseErr != nil {
+			return "", fmt.Errorf("after applying all patches, the file no longer parses:\n%w", parseErr)
+		}
+	}
+
+	if autogenerated {
+		fmt.Fprintf(response, "- WARNING: %q appears to be autogenerated. Patches were applied anyway.\n", input.Path)
+	}
+
+	// TODO: maybe report the patch result to the model, i.e. some/all of the new code after the patches and formatting.
+	return response.String(), nil
+}
+
+func parseGo(buf []byte) error {
+	fset := token.NewFileSet()
+	_, err := parser.ParseFile(fset, "", buf, parser.SkipObjectResolution)
+	return err
+}
+
+func isAutogeneratedGoFile(buf []byte) bool {
+	for _, sig := range autogeneratedSignals {
+		if bytes.Contains(buf, []byte(sig)) {
+			return true
+		}
+	}
+
+	// https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source
+	// "This line must appear before the first non-comment, non-blank text in the file."
+	// Approximate that by looking for it at the top of the file, before the last of the imports.
+	// (Sometimes people put it after the package declaration, because of course they do.)
+	// At least in the imports region we know it's not part of their actual code;
+	// we don't want to ignore the generator (which also includes these strings!),
+	// just the generated code.
+	fset := token.NewFileSet()
+	f, err := parser.ParseFile(fset, "x.go", buf, parser.ImportsOnly|parser.ParseComments)
+	if err == nil {
+		for _, cg := range f.Comments {
+			t := strings.ToLower(cg.Text())
+			for _, sig := range autogeneratedHeaderSignals {
+				if strings.Contains(t, sig) {
+					return true
+				}
+			}
+		}
+	}
+
+	return false
+}
+
+// autogeneratedSignals are signals that a file is autogenerated, when present anywhere in the file.
+var autogeneratedSignals = [][]byte{
+	[]byte("\nfunc bindataRead("), // pre-embed bindata packed file
+}
+
+// autogeneratedHeaderSignals are signals that a file is autogenerated, when present at the top of the file.
+var autogeneratedHeaderSignals = []string{
+	// canonical would be `(?m)^// Code generated .* DO NOT EDIT\.$`
+	// but people screw it up, a lot, so be more lenient
+	strings.ToLower("generate"),
+	strings.ToLower("DO NOT EDIT"),
+	strings.ToLower("export by"),
+}