| import fs from "node:fs/promises"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import type { AgentTool } from "@mariozechner/pi-agent-core"; |
| import { Type } from "@sinclair/typebox"; |
| import { applyUpdateHunk } from "./apply-patch-update.js"; |
| import { assertSandboxPath } from "./sandbox-paths.js"; |
|
|
| const BEGIN_PATCH_MARKER = "*** Begin Patch"; |
| const END_PATCH_MARKER = "*** End Patch"; |
| const ADD_FILE_MARKER = "*** Add File: "; |
| const DELETE_FILE_MARKER = "*** Delete File: "; |
| const UPDATE_FILE_MARKER = "*** Update File: "; |
| const MOVE_TO_MARKER = "*** Move to: "; |
| const EOF_MARKER = "*** End of File"; |
| const CHANGE_CONTEXT_MARKER = "@@ "; |
| const EMPTY_CHANGE_CONTEXT_MARKER = "@@"; |
| const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; |
|
|
| type AddFileHunk = { |
| kind: "add"; |
| path: string; |
| contents: string; |
| }; |
|
|
| type DeleteFileHunk = { |
| kind: "delete"; |
| path: string; |
| }; |
|
|
| type UpdateFileChunk = { |
| changeContext?: string; |
| oldLines: string[]; |
| newLines: string[]; |
| isEndOfFile: boolean; |
| }; |
|
|
| type UpdateFileHunk = { |
| kind: "update"; |
| path: string; |
| movePath?: string; |
| chunks: UpdateFileChunk[]; |
| }; |
|
|
| type Hunk = AddFileHunk | DeleteFileHunk | UpdateFileHunk; |
|
|
| export type ApplyPatchSummary = { |
| added: string[]; |
| modified: string[]; |
| deleted: string[]; |
| }; |
|
|
| export type ApplyPatchResult = { |
| summary: ApplyPatchSummary; |
| text: string; |
| }; |
|
|
| export type ApplyPatchToolDetails = { |
| summary: ApplyPatchSummary; |
| }; |
|
|
| type ApplyPatchOptions = { |
| cwd: string; |
| sandboxRoot?: string; |
| signal?: AbortSignal; |
| }; |
|
|
| const applyPatchSchema = Type.Object({ |
| input: Type.String({ |
| description: "Patch content using the *** Begin Patch/End Patch format.", |
| }), |
| }); |
|
|
| export function createApplyPatchTool( |
| options: { cwd?: string; sandboxRoot?: string } = {}, |
| |
| ): AgentTool<any, ApplyPatchToolDetails> { |
| const cwd = options.cwd ?? process.cwd(); |
| const sandboxRoot = options.sandboxRoot; |
|
|
| return { |
| name: "apply_patch", |
| label: "apply_patch", |
| description: |
| "Apply a patch to one or more files using the apply_patch format. The input should include *** Begin Patch and *** End Patch markers.", |
| parameters: applyPatchSchema, |
| execute: async (_toolCallId, args, signal) => { |
| const params = args as { input?: string }; |
| const input = typeof params.input === "string" ? params.input : ""; |
| if (!input.trim()) { |
| throw new Error("Provide a patch input."); |
| } |
| if (signal?.aborted) { |
| const err = new Error("Aborted"); |
| err.name = "AbortError"; |
| throw err; |
| } |
|
|
| const result = await applyPatch(input, { |
| cwd, |
| sandboxRoot, |
| signal, |
| }); |
|
|
| return { |
| content: [{ type: "text", text: result.text }], |
| details: { summary: result.summary }, |
| }; |
| }, |
| }; |
| } |
|
|
| export async function applyPatch( |
| input: string, |
| options: ApplyPatchOptions, |
| ): Promise<ApplyPatchResult> { |
| const parsed = parsePatchText(input); |
| if (parsed.hunks.length === 0) { |
| throw new Error("No files were modified."); |
| } |
|
|
| const summary: ApplyPatchSummary = { |
| added: [], |
| modified: [], |
| deleted: [], |
| }; |
| const seen = { |
| added: new Set<string>(), |
| modified: new Set<string>(), |
| deleted: new Set<string>(), |
| }; |
|
|
| for (const hunk of parsed.hunks) { |
| if (options.signal?.aborted) { |
| const err = new Error("Aborted"); |
| err.name = "AbortError"; |
| throw err; |
| } |
|
|
| if (hunk.kind === "add") { |
| const target = await resolvePatchPath(hunk.path, options); |
| await ensureDir(target.resolved); |
| await fs.writeFile(target.resolved, hunk.contents, "utf8"); |
| recordSummary(summary, seen, "added", target.display); |
| continue; |
| } |
|
|
| if (hunk.kind === "delete") { |
| const target = await resolvePatchPath(hunk.path, options); |
| await fs.rm(target.resolved); |
| recordSummary(summary, seen, "deleted", target.display); |
| continue; |
| } |
|
|
| const target = await resolvePatchPath(hunk.path, options); |
| const applied = await applyUpdateHunk(target.resolved, hunk.chunks); |
|
|
| if (hunk.movePath) { |
| const moveTarget = await resolvePatchPath(hunk.movePath, options); |
| await ensureDir(moveTarget.resolved); |
| await fs.writeFile(moveTarget.resolved, applied, "utf8"); |
| await fs.rm(target.resolved); |
| recordSummary(summary, seen, "modified", moveTarget.display); |
| } else { |
| await fs.writeFile(target.resolved, applied, "utf8"); |
| recordSummary(summary, seen, "modified", target.display); |
| } |
| } |
|
|
| return { |
| summary, |
| text: formatSummary(summary), |
| }; |
| } |
|
|
| function recordSummary( |
| summary: ApplyPatchSummary, |
| seen: { |
| added: Set<string>; |
| modified: Set<string>; |
| deleted: Set<string>; |
| }, |
| bucket: keyof ApplyPatchSummary, |
| value: string, |
| ) { |
| if (seen[bucket].has(value)) { |
| return; |
| } |
| seen[bucket].add(value); |
| summary[bucket].push(value); |
| } |
|
|
| function formatSummary(summary: ApplyPatchSummary): string { |
| const lines = ["Success. Updated the following files:"]; |
| for (const file of summary.added) { |
| lines.push(`A ${file}`); |
| } |
| for (const file of summary.modified) { |
| lines.push(`M ${file}`); |
| } |
| for (const file of summary.deleted) { |
| lines.push(`D ${file}`); |
| } |
| return lines.join("\n"); |
| } |
|
|
| async function ensureDir(filePath: string) { |
| const parent = path.dirname(filePath); |
| if (!parent || parent === ".") { |
| return; |
| } |
| await fs.mkdir(parent, { recursive: true }); |
| } |
|
|
| async function resolvePatchPath( |
| filePath: string, |
| options: ApplyPatchOptions, |
| ): Promise<{ resolved: string; display: string }> { |
| if (options.sandboxRoot) { |
| const resolved = await assertSandboxPath({ |
| filePath, |
| cwd: options.cwd, |
| root: options.sandboxRoot, |
| }); |
| return { |
| resolved: resolved.resolved, |
| display: resolved.relative || resolved.resolved, |
| }; |
| } |
|
|
| const resolved = resolvePathFromCwd(filePath, options.cwd); |
| return { |
| resolved, |
| display: toDisplayPath(resolved, options.cwd), |
| }; |
| } |
|
|
| function normalizeUnicodeSpaces(value: string): string { |
| return value.replace(UNICODE_SPACES, " "); |
| } |
|
|
| function expandPath(filePath: string): string { |
| const normalized = normalizeUnicodeSpaces(filePath); |
| if (normalized === "~") { |
| return os.homedir(); |
| } |
| if (normalized.startsWith("~/")) { |
| return os.homedir() + normalized.slice(1); |
| } |
| return normalized; |
| } |
|
|
| function resolvePathFromCwd(filePath: string, cwd: string): string { |
| const expanded = expandPath(filePath); |
| if (path.isAbsolute(expanded)) { |
| return path.normalize(expanded); |
| } |
| return path.resolve(cwd, expanded); |
| } |
|
|
| function toDisplayPath(resolved: string, cwd: string): string { |
| const relative = path.relative(cwd, resolved); |
| if (!relative || relative === "") { |
| return path.basename(resolved); |
| } |
| if (relative.startsWith("..") || path.isAbsolute(relative)) { |
| return resolved; |
| } |
| return relative; |
| } |
|
|
| function parsePatchText(input: string): { hunks: Hunk[]; patch: string } { |
| const trimmed = input.trim(); |
| if (!trimmed) { |
| throw new Error("Invalid patch: input is empty."); |
| } |
|
|
| const lines = trimmed.split(/\r?\n/); |
| const validated = checkPatchBoundariesLenient(lines); |
| const hunks: Hunk[] = []; |
|
|
| const lastLineIndex = validated.length - 1; |
| let remaining = validated.slice(1, lastLineIndex); |
| let lineNumber = 2; |
|
|
| while (remaining.length > 0) { |
| const { hunk, consumed } = parseOneHunk(remaining, lineNumber); |
| hunks.push(hunk); |
| lineNumber += consumed; |
| remaining = remaining.slice(consumed); |
| } |
|
|
| return { hunks, patch: validated.join("\n") }; |
| } |
|
|
| function checkPatchBoundariesLenient(lines: string[]): string[] { |
| const strictError = checkPatchBoundariesStrict(lines); |
| if (!strictError) { |
| return lines; |
| } |
|
|
| if (lines.length < 4) { |
| throw new Error(strictError); |
| } |
| const first = lines[0]; |
| const last = lines[lines.length - 1]; |
| if ((first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"') && last.endsWith("EOF")) { |
| const inner = lines.slice(1, lines.length - 1); |
| const innerError = checkPatchBoundariesStrict(inner); |
| if (!innerError) { |
| return inner; |
| } |
| throw new Error(innerError); |
| } |
|
|
| throw new Error(strictError); |
| } |
|
|
| function checkPatchBoundariesStrict(lines: string[]): string | null { |
| const firstLine = lines[0]?.trim(); |
| const lastLine = lines[lines.length - 1]?.trim(); |
|
|
| if (firstLine === BEGIN_PATCH_MARKER && lastLine === END_PATCH_MARKER) { |
| return null; |
| } |
| if (firstLine !== BEGIN_PATCH_MARKER) { |
| return "The first line of the patch must be '*** Begin Patch'"; |
| } |
| return "The last line of the patch must be '*** End Patch'"; |
| } |
|
|
| function parseOneHunk(lines: string[], lineNumber: number): { hunk: Hunk; consumed: number } { |
| if (lines.length === 0) { |
| throw new Error(`Invalid patch hunk at line ${lineNumber}: empty hunk`); |
| } |
| const firstLine = lines[0].trim(); |
| if (firstLine.startsWith(ADD_FILE_MARKER)) { |
| const targetPath = firstLine.slice(ADD_FILE_MARKER.length); |
| let contents = ""; |
| let consumed = 1; |
| for (const addLine of lines.slice(1)) { |
| if (addLine.startsWith("+")) { |
| contents += `${addLine.slice(1)}\n`; |
| consumed += 1; |
| } else { |
| break; |
| } |
| } |
| return { |
| hunk: { kind: "add", path: targetPath, contents }, |
| consumed, |
| }; |
| } |
|
|
| if (firstLine.startsWith(DELETE_FILE_MARKER)) { |
| const targetPath = firstLine.slice(DELETE_FILE_MARKER.length); |
| return { |
| hunk: { kind: "delete", path: targetPath }, |
| consumed: 1, |
| }; |
| } |
|
|
| if (firstLine.startsWith(UPDATE_FILE_MARKER)) { |
| const targetPath = firstLine.slice(UPDATE_FILE_MARKER.length); |
| let remaining = lines.slice(1); |
| let consumed = 1; |
| let movePath: string | undefined; |
|
|
| const moveCandidate = remaining[0]?.trim(); |
| if (moveCandidate?.startsWith(MOVE_TO_MARKER)) { |
| movePath = moveCandidate.slice(MOVE_TO_MARKER.length); |
| remaining = remaining.slice(1); |
| consumed += 1; |
| } |
|
|
| const chunks: UpdateFileChunk[] = []; |
| while (remaining.length > 0) { |
| if (remaining[0].trim() === "") { |
| remaining = remaining.slice(1); |
| consumed += 1; |
| continue; |
| } |
| if (remaining[0].startsWith("***")) { |
| break; |
| } |
| const { chunk, consumed: chunkLines } = parseUpdateFileChunk( |
| remaining, |
| lineNumber + consumed, |
| chunks.length === 0, |
| ); |
| chunks.push(chunk); |
| remaining = remaining.slice(chunkLines); |
| consumed += chunkLines; |
| } |
|
|
| if (chunks.length === 0) { |
| throw new Error( |
| `Invalid patch hunk at line ${lineNumber}: Update file hunk for path '${targetPath}' is empty`, |
| ); |
| } |
|
|
| return { |
| hunk: { |
| kind: "update", |
| path: targetPath, |
| movePath, |
| chunks, |
| }, |
| consumed, |
| }; |
| } |
|
|
| throw new Error( |
| `Invalid patch hunk at line ${lineNumber}: '${lines[0]}' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'`, |
| ); |
| } |
|
|
| function parseUpdateFileChunk( |
| lines: string[], |
| lineNumber: number, |
| allowMissingContext: boolean, |
| ): { chunk: UpdateFileChunk; consumed: number } { |
| if (lines.length === 0) { |
| throw new Error( |
| `Invalid patch hunk at line ${lineNumber}: Update hunk does not contain any lines`, |
| ); |
| } |
|
|
| let changeContext: string | undefined; |
| let startIndex = 0; |
| if (lines[0] === EMPTY_CHANGE_CONTEXT_MARKER) { |
| startIndex = 1; |
| } else if (lines[0].startsWith(CHANGE_CONTEXT_MARKER)) { |
| changeContext = lines[0].slice(CHANGE_CONTEXT_MARKER.length); |
| startIndex = 1; |
| } else if (!allowMissingContext) { |
| throw new Error( |
| `Invalid patch hunk at line ${lineNumber}: Expected update hunk to start with a @@ context marker, got: '${lines[0]}'`, |
| ); |
| } |
|
|
| if (startIndex >= lines.length) { |
| throw new Error( |
| `Invalid patch hunk at line ${lineNumber + 1}: Update hunk does not contain any lines`, |
| ); |
| } |
|
|
| const chunk: UpdateFileChunk = { |
| changeContext, |
| oldLines: [], |
| newLines: [], |
| isEndOfFile: false, |
| }; |
|
|
| let parsedLines = 0; |
| for (const line of lines.slice(startIndex)) { |
| if (line === EOF_MARKER) { |
| if (parsedLines === 0) { |
| throw new Error( |
| `Invalid patch hunk at line ${lineNumber + 1}: Update hunk does not contain any lines`, |
| ); |
| } |
| chunk.isEndOfFile = true; |
| parsedLines += 1; |
| break; |
| } |
|
|
| const marker = line[0]; |
| if (!marker) { |
| chunk.oldLines.push(""); |
| chunk.newLines.push(""); |
| parsedLines += 1; |
| continue; |
| } |
|
|
| if (marker === " ") { |
| const content = line.slice(1); |
| chunk.oldLines.push(content); |
| chunk.newLines.push(content); |
| parsedLines += 1; |
| continue; |
| } |
| if (marker === "+") { |
| chunk.newLines.push(line.slice(1)); |
| parsedLines += 1; |
| continue; |
| } |
| if (marker === "-") { |
| chunk.oldLines.push(line.slice(1)); |
| parsedLines += 1; |
| continue; |
| } |
|
|
| if (parsedLines === 0) { |
| throw new Error( |
| `Invalid patch hunk at line ${lineNumber + 1}: Unexpected line found in update hunk: '${line}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)`, |
| ); |
| } |
| break; |
| } |
|
|
| return { chunk, consumed: parsedLines + startIndex }; |
| } |
|
|