|
@@ -0,0 +1,420 @@
|
|
|
|
|
+import { z } from "zod";
|
|
|
|
|
+import * as path from "path";
|
|
|
|
|
+import * as fs from "fs/promises";
|
|
|
|
|
+import { Tool } from "./tool";
|
|
|
|
|
+import { FileTimes } from "./util/file-times";
|
|
|
|
|
+
|
|
|
|
|
+const DESCRIPTION = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
|
|
|
|
|
+
|
|
|
|
|
+The patch text must follow this format:
|
|
|
|
|
+*** Begin Patch
|
|
|
|
|
+*** Update File: /path/to/file
|
|
|
|
|
+@@ Context line (unique within the file)
|
|
|
|
|
+ Line to keep
|
|
|
|
|
+-Line to remove
|
|
|
|
|
++Line to add
|
|
|
|
|
+ Line to keep
|
|
|
|
|
+*** Add File: /path/to/new/file
|
|
|
|
|
++Content of the new file
|
|
|
|
|
++More content
|
|
|
|
|
+*** Delete File: /path/to/file/to/delete
|
|
|
|
|
+*** End Patch
|
|
|
|
|
+
|
|
|
|
|
+Before using this tool:
|
|
|
|
|
+1. Use the FileRead tool to understand the files' contents and context
|
|
|
|
|
+2. Verify all file paths are correct (use the LS tool)
|
|
|
|
|
+
|
|
|
|
|
+CRITICAL REQUIREMENTS FOR USING THIS TOOL:
|
|
|
|
|
+
|
|
|
|
|
+1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
|
|
|
|
|
+2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
|
|
|
|
|
+3. VALIDATION: Ensure edits result in idiomatic, correct code
|
|
|
|
|
+4. PATHS: Always use absolute file paths (starting with /)
|
|
|
|
|
+
|
|
|
|
|
+The tool will apply all changes in a single atomic operation.`;
|
|
|
|
|
+
|
|
|
|
|
+const PatchParams = z.object({
|
|
|
|
|
+ patch_text: z
|
|
|
|
|
+ .string()
|
|
|
|
|
+ .describe("The full patch text that describes all changes to be made"),
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+interface PatchResponseMetadata {
|
|
|
|
|
+ changed: string[];
|
|
|
|
|
+ additions: number;
|
|
|
|
|
+ removals: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Change {
|
|
|
|
|
+ type: "add" | "update" | "delete";
|
|
|
|
|
+ old_content?: string;
|
|
|
|
|
+ new_content?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Commit {
|
|
|
|
|
+ changes: Record<string, Change>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PatchOperation {
|
|
|
|
|
+ type: "update" | "add" | "delete";
|
|
|
|
|
+ filePath: string;
|
|
|
|
|
+ hunks?: PatchHunk[];
|
|
|
|
|
+ content?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PatchHunk {
|
|
|
|
|
+ contextLine: string;
|
|
|
|
|
+ changes: PatchChange[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PatchChange {
|
|
|
|
|
+ type: "keep" | "remove" | "add";
|
|
|
|
|
+ content: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function identifyFilesNeeded(patchText: string): string[] {
|
|
|
|
|
+ const files: string[] = [];
|
|
|
|
|
+ const lines = patchText.split("\n");
|
|
|
|
|
+ for (const line of lines) {
|
|
|
|
|
+ if (
|
|
|
|
|
+ line.startsWith("*** Update File:") ||
|
|
|
|
|
+ line.startsWith("*** Delete File:")
|
|
|
|
|
+ ) {
|
|
|
|
|
+ const filePath = line.split(":", 2)[1]?.trim();
|
|
|
|
|
+ if (filePath) files.push(filePath);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return files;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function identifyFilesAdded(patchText: string): string[] {
|
|
|
|
|
+ const files: string[] = [];
|
|
|
|
|
+ const lines = patchText.split("\n");
|
|
|
|
|
+ for (const line of lines) {
|
|
|
|
|
+ if (line.startsWith("*** Add File:")) {
|
|
|
|
|
+ const filePath = line.split(":", 2)[1]?.trim();
|
|
|
|
|
+ if (filePath) files.push(filePath);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return files;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function textToPatch(
|
|
|
|
|
+ patchText: string,
|
|
|
|
|
+ _currentFiles: Record<string, string>,
|
|
|
|
|
+): [PatchOperation[], number] {
|
|
|
|
|
+ const operations: PatchOperation[] = [];
|
|
|
|
|
+ const lines = patchText.split("\n");
|
|
|
|
|
+ let i = 0;
|
|
|
|
|
+ let fuzz = 0;
|
|
|
|
|
+
|
|
|
|
|
+ while (i < lines.length) {
|
|
|
|
|
+ const line = lines[i];
|
|
|
|
|
+
|
|
|
|
|
+ if (line.startsWith("*** Update File:")) {
|
|
|
|
|
+ const filePath = line.split(":", 2)[1]?.trim();
|
|
|
|
|
+ if (!filePath) {
|
|
|
|
|
+ i++;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const hunks: PatchHunk[] = [];
|
|
|
|
|
+ i++;
|
|
|
|
|
+
|
|
|
|
|
+ while (i < lines.length && !lines[i].startsWith("***")) {
|
|
|
|
|
+ if (lines[i].startsWith("@@")) {
|
|
|
|
|
+ const contextLine = lines[i].substring(2).trim();
|
|
|
|
|
+ const changes: PatchChange[] = [];
|
|
|
|
|
+ i++;
|
|
|
|
|
+
|
|
|
|
|
+ while (
|
|
|
|
|
+ i < lines.length &&
|
|
|
|
|
+ !lines[i].startsWith("@@") &&
|
|
|
|
|
+ !lines[i].startsWith("***")
|
|
|
|
|
+ ) {
|
|
|
|
|
+ const changeLine = lines[i];
|
|
|
|
|
+ if (changeLine.startsWith(" ")) {
|
|
|
|
|
+ changes.push({ type: "keep", content: changeLine.substring(1) });
|
|
|
|
|
+ } else if (changeLine.startsWith("-")) {
|
|
|
|
|
+ changes.push({
|
|
|
|
|
+ type: "remove",
|
|
|
|
|
+ content: changeLine.substring(1),
|
|
|
|
|
+ });
|
|
|
|
|
+ } else if (changeLine.startsWith("+")) {
|
|
|
|
|
+ changes.push({ type: "add", content: changeLine.substring(1) });
|
|
|
|
|
+ }
|
|
|
|
|
+ i++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ hunks.push({ contextLine, changes });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ i++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ operations.push({ type: "update", filePath, hunks });
|
|
|
|
|
+ } else if (line.startsWith("*** Add File:")) {
|
|
|
|
|
+ const filePath = line.split(":", 2)[1]?.trim();
|
|
|
|
|
+ if (!filePath) {
|
|
|
|
|
+ i++;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let content = "";
|
|
|
|
|
+ i++;
|
|
|
|
|
+
|
|
|
|
|
+ while (i < lines.length && !lines[i].startsWith("***")) {
|
|
|
|
|
+ if (lines[i].startsWith("+")) {
|
|
|
|
|
+ content += lines[i].substring(1) + "\n";
|
|
|
|
|
+ }
|
|
|
|
|
+ i++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ operations.push({ type: "add", filePath, content: content.slice(0, -1) });
|
|
|
|
|
+ } else if (line.startsWith("*** Delete File:")) {
|
|
|
|
|
+ const filePath = line.split(":", 2)[1]?.trim();
|
|
|
|
|
+ if (filePath) {
|
|
|
|
|
+ operations.push({ type: "delete", filePath });
|
|
|
|
|
+ }
|
|
|
|
|
+ i++;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ i++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [operations, fuzz];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function patchToCommit(
|
|
|
|
|
+ operations: PatchOperation[],
|
|
|
|
|
+ currentFiles: Record<string, string>,
|
|
|
|
|
+): Commit {
|
|
|
|
|
+ const changes: Record<string, Change> = {};
|
|
|
|
|
+
|
|
|
|
|
+ for (const op of operations) {
|
|
|
|
|
+ if (op.type === "delete") {
|
|
|
|
|
+ changes[op.filePath] = {
|
|
|
|
|
+ type: "delete",
|
|
|
|
|
+ old_content: currentFiles[op.filePath] || "",
|
|
|
|
|
+ };
|
|
|
|
|
+ } else if (op.type === "add") {
|
|
|
|
|
+ changes[op.filePath] = {
|
|
|
|
|
+ type: "add",
|
|
|
|
|
+ new_content: op.content || "",
|
|
|
|
|
+ };
|
|
|
|
|
+ } else if (op.type === "update" && op.hunks) {
|
|
|
|
|
+ const originalContent = currentFiles[op.filePath] || "";
|
|
|
|
|
+ const lines = originalContent.split("\n");
|
|
|
|
|
+
|
|
|
|
|
+ for (const hunk of op.hunks) {
|
|
|
|
|
+ const contextIndex = lines.findIndex((line) =>
|
|
|
|
|
+ line.includes(hunk.contextLine),
|
|
|
|
|
+ );
|
|
|
|
|
+ if (contextIndex === -1) {
|
|
|
|
|
+ throw new Error(`Context line not found: ${hunk.contextLine}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let currentIndex = contextIndex;
|
|
|
|
|
+ for (const change of hunk.changes) {
|
|
|
|
|
+ if (change.type === "keep") {
|
|
|
|
|
+ currentIndex++;
|
|
|
|
|
+ } else if (change.type === "remove") {
|
|
|
|
|
+ lines.splice(currentIndex, 1);
|
|
|
|
|
+ } else if (change.type === "add") {
|
|
|
|
|
+ lines.splice(currentIndex, 0, change.content);
|
|
|
|
|
+ currentIndex++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ changes[op.filePath] = {
|
|
|
|
|
+ type: "update",
|
|
|
|
|
+ old_content: originalContent,
|
|
|
|
|
+ new_content: lines.join("\n"),
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { changes };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function generateDiff(
|
|
|
|
|
+ oldContent: string,
|
|
|
|
|
+ newContent: string,
|
|
|
|
|
+ filePath: string,
|
|
|
|
|
+): [string, number, number] {
|
|
|
|
|
+ // Mock implementation - would need actual diff generation
|
|
|
|
|
+ const lines1 = oldContent.split("\n");
|
|
|
|
|
+ const lines2 = newContent.split("\n");
|
|
|
|
|
+ const additions = Math.max(0, lines2.length - lines1.length);
|
|
|
|
|
+ const removals = Math.max(0, lines1.length - lines2.length);
|
|
|
|
|
+ return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function applyCommit(
|
|
|
|
|
+ commit: Commit,
|
|
|
|
|
+ writeFile: (path: string, content: string) => Promise<void>,
|
|
|
|
|
+ deleteFile: (path: string) => Promise<void>,
|
|
|
|
|
+): Promise<void> {
|
|
|
|
|
+ for (const [filePath, change] of Object.entries(commit.changes)) {
|
|
|
|
|
+ if (change.type === "delete") {
|
|
|
|
|
+ await deleteFile(filePath);
|
|
|
|
|
+ } else if (change.new_content !== undefined) {
|
|
|
|
|
+ await writeFile(filePath, change.new_content);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export const patch = Tool.define({
|
|
|
|
|
+ name: "patch",
|
|
|
|
|
+ description: DESCRIPTION,
|
|
|
|
|
+ parameters: PatchParams,
|
|
|
|
|
+ execute: async ({ patch_text }) => {
|
|
|
|
|
+ if (!patch_text) {
|
|
|
|
|
+ throw new Error("patch_text is required");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Identify all files needed for the patch and verify they've been read
|
|
|
|
|
+ const filesToRead = identifyFilesNeeded(patch_text);
|
|
|
|
|
+ for (const filePath of filesToRead) {
|
|
|
|
|
+ let absPath = filePath;
|
|
|
|
|
+ if (!path.isAbsolute(absPath)) {
|
|
|
|
|
+ absPath = path.resolve(process.cwd(), absPath);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!FileTimes.get(absPath)) {
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ `you must read the file ${filePath} before patching it. Use the FileRead tool first`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const stats = await fs.stat(absPath);
|
|
|
|
|
+ if (stats.isDirectory()) {
|
|
|
|
|
+ throw new Error(`path is a directory, not a file: ${absPath}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const lastRead = FileTimes.get(absPath);
|
|
|
|
|
+ if (lastRead && stats.mtime > lastRead) {
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ `file ${absPath} has been modified since it was last read (mod time: ${stats.mtime.toISOString()}, last read: ${lastRead.toISOString()})`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ if (error.code === "ENOENT") {
|
|
|
|
|
+ throw new Error(`file not found: ${absPath}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error(`failed to access file: ${error.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Check for new files to ensure they don't already exist
|
|
|
|
|
+ const filesToAdd = identifyFilesAdded(patch_text);
|
|
|
|
|
+ for (const filePath of filesToAdd) {
|
|
|
|
|
+ let absPath = filePath;
|
|
|
|
|
+ if (!path.isAbsolute(absPath)) {
|
|
|
|
|
+ absPath = path.resolve(process.cwd(), absPath);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await fs.stat(absPath);
|
|
|
|
|
+ throw new Error(`file already exists and cannot be added: ${absPath}`);
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ if (error.code !== "ENOENT") {
|
|
|
|
|
+ throw new Error(`failed to check file: ${error.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Load all required files
|
|
|
|
|
+ const currentFiles: Record<string, string> = {};
|
|
|
|
|
+ for (const filePath of filesToRead) {
|
|
|
|
|
+ let absPath = filePath;
|
|
|
|
|
+ if (!path.isAbsolute(absPath)) {
|
|
|
|
|
+ absPath = path.resolve(process.cwd(), absPath);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const content = await fs.readFile(absPath, "utf-8");
|
|
|
|
|
+ currentFiles[filePath] = content;
|
|
|
|
|
+ } catch (error: any) {
|
|
|
|
|
+ throw new Error(`failed to read file ${absPath}: ${error.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Process the patch
|
|
|
|
|
+ const [patch, fuzz] = textToPatch(patch_text, currentFiles);
|
|
|
|
|
+ if (fuzz > 3) {
|
|
|
|
|
+ throw new Error(
|
|
|
|
|
+ `patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Convert patch to commit
|
|
|
|
|
+ const commit = patchToCommit(patch, currentFiles);
|
|
|
|
|
+
|
|
|
|
|
+ // Apply the changes to the filesystem
|
|
|
|
|
+ await applyCommit(
|
|
|
|
|
+ commit,
|
|
|
|
|
+ async (filePath: string, content: string) => {
|
|
|
|
|
+ let absPath = filePath;
|
|
|
|
|
+ if (!path.isAbsolute(absPath)) {
|
|
|
|
|
+ absPath = path.resolve(process.cwd(), absPath);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Create parent directories if needed
|
|
|
|
|
+ const dir = path.dirname(absPath);
|
|
|
|
|
+ await fs.mkdir(dir, { recursive: true });
|
|
|
|
|
+ await fs.writeFile(absPath, content, "utf-8");
|
|
|
|
|
+ },
|
|
|
|
|
+ async (filePath: string) => {
|
|
|
|
|
+ let absPath = filePath;
|
|
|
|
|
+ if (!path.isAbsolute(absPath)) {
|
|
|
|
|
+ absPath = path.resolve(process.cwd(), absPath);
|
|
|
|
|
+ }
|
|
|
|
|
+ await fs.unlink(absPath);
|
|
|
|
|
+ },
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate statistics
|
|
|
|
|
+ const changedFiles: string[] = [];
|
|
|
|
|
+ let totalAdditions = 0;
|
|
|
|
|
+ let totalRemovals = 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (const [filePath, change] of Object.entries(commit.changes)) {
|
|
|
|
|
+ let absPath = filePath;
|
|
|
|
|
+ if (!path.isAbsolute(absPath)) {
|
|
|
|
|
+ absPath = path.resolve(process.cwd(), absPath);
|
|
|
|
|
+ }
|
|
|
|
|
+ changedFiles.push(absPath);
|
|
|
|
|
+
|
|
|
|
|
+ const oldContent = change.old_content || "";
|
|
|
|
|
+ const newContent = change.new_content || "";
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate diff statistics
|
|
|
|
|
+ const [, additions, removals] = generateDiff(
|
|
|
|
|
+ oldContent,
|
|
|
|
|
+ newContent,
|
|
|
|
|
+ filePath,
|
|
|
|
|
+ );
|
|
|
|
|
+ totalAdditions += additions;
|
|
|
|
|
+ totalRemovals += removals;
|
|
|
|
|
+
|
|
|
|
|
+ // Record file operations
|
|
|
|
|
+ FileTimes.write(absPath);
|
|
|
|
|
+ FileTimes.read(absPath);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`;
|
|
|
|
|
+ const output = result;
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ metadata: {
|
|
|
|
|
+ changed: changedFiles,
|
|
|
|
|
+ additions: totalAdditions,
|
|
|
|
|
+ removals: totalRemovals,
|
|
|
|
|
+ } satisfies PatchResponseMetadata,
|
|
|
|
|
+ output,
|
|
|
|
|
+ };
|
|
|
|
|
+ },
|
|
|
|
|
+});
|