Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231/** * edit — built-in tool to edit a file by exact-text replacement (ARCHITECTURE §4.1). * * Replaces a single, unambiguous occurrence of `oldText` with `newText` in an * existing file (resolved relative to cwd). Does NOT create files or directories — * use `write` for that. Emits a unified-diff style `DiffDescriptor` via the tool's * `render` field and `onResult` hook so clients can show the change without per-client * adapters. * - Empty `oldText`, zero matches, or multiple matches each produce an `isError` * result — the tool never throws across the seam. */import { Type, type Static } from '@sinclair/typebox';import { Value } from '@sinclair/typebox/value';import { resolve } from 'node:path';import type { Tool, ToolContext, ToolResult } from '@/contracts/tool';import type { Hunk, RenderDescriptor } from '@/contracts/render-descriptor';
/** Lines of context shown around the changed region in the diff hunk. */const DEFAULT_DIFF_CONTEXT = 3;
// ── Input schema ──────────────────────────────────────────────────────────────
export const EditInputSchema = Type.Object({ path: Type.String({ description: 'File path, resolved relative to cwd' }), oldText: Type.String({ description: 'Exact text to find. Must match exactly one occurrence.', }), newText: Type.String({ description: 'Replacement text' }),});
export type EditInput = Static<typeof EditInputSchema>;
// ── Diff hunk computation ─────────────────────────────────────────────────────
/** * Compute a single unified-diff `Hunk` covering the contiguous changed region * between `before` and `after`, with `context` lines of surrounding context. * * Assumes exactly one localized replacement (which `edit` performs). Lines are * the full file text split on `\n`, so a trailing newline yields a trailing `''` * element — this is preserved for fidelity. */export function computeDiffHunk( before: string, after: string, context = DEFAULT_DIFF_CONTEXT,): Hunk { const oldLines = before.split('\n'); const newLines = after.split('\n');
// Common prefix (whole-line equality). const maxPrefix = Math.min(oldLines.length, newLines.length); let prefix = 0; while (prefix < maxPrefix && oldLines[prefix] === newLines[prefix]) { prefix++; }
// Common suffix (whole-line equality), not overlapping the prefix. const maxSuffix = Math.min(oldLines.length - prefix, newLines.length - prefix); let suffix = 0; while ( suffix < maxSuffix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] ) { suffix++; }
// Changed region (0-indexed, inclusive) in both arrays. const firstDiffOld = prefix; const lastDiffOld = oldLines.length - 1 - suffix; const firstDiffNew = prefix; const lastDiffNew = newLines.length - 1 - suffix;
// Hunk bounds with context, clamped to file bounds. const hunkStartOld = Math.max(0, firstDiffOld - context); const hunkEndOld = Math.min(oldLines.length - 1, lastDiffOld + context); const hunkStartNew = Math.max(0, firstDiffNew - context); const hunkEndNew = Math.min(newLines.length - 1, lastDiffNew + context);
const lines: string[] = []; // Context before the change (identical in both; read from old). for (let i = hunkStartOld; i < firstDiffOld; i++) { lines.push(` ${oldLines[i]}`); } // Removed lines. for (let i = firstDiffOld; i <= lastDiffOld; i++) { lines.push(`-${oldLines[i]}`); } // Added lines. for (let i = firstDiffNew; i <= lastDiffNew; i++) { lines.push(`+${newLines[i]}`); } // Context after the change (identical in both; read from new). for (let i = lastDiffNew + 1; i <= hunkEndNew; i++) { lines.push(` ${newLines[i]}`); }
return { oldStart: hunkStartOld + 1, // 1-indexed oldLines: hunkEndOld - hunkStartOld + 1, newStart: hunkStartNew + 1, // 1-indexed newLines: hunkEndNew - hunkStartNew + 1, lines, };}
// ── Tool definition ───────────────────────────────────────────────────────────
export const editTool: Tool<typeof EditInputSchema> = { name: 'edit', description: 'Edit an existing file by exact-text replacement. Finds the single occurrence of ' + '`oldText` and replaces it with `newText`. Fails if oldText is empty, not found, or ' + 'matches more than once (ambiguous — include more surrounding context). ' + 'Does not create files; use `write` for new files.', inputSchema: EditInputSchema, isReadOnly: false,
async execute(input: EditInput, ctx: ToolContext): Promise<ToolResult> { // Validate input against schema if (!Value.Check(EditInputSchema, input)) { return { content: { kind: 'error', message: `Invalid input for edit tool: ${JSON.stringify(input)}`, }, isError: true, }; }
const filePath = resolve(ctx.cwd, input.path);
// Reject empty oldText — it is ambiguous and a no-op. if (input.oldText.length === 0) { return { content: { kind: 'error', message: `edit ${input.path}: oldText must not be empty`, }, isError: true, }; }
try { const file = Bun.file(filePath); const exists = await file.exists(); if (!exists) { return { content: { kind: 'error', message: `edit ${input.path}: file not found (${filePath}) — use the write tool to create files`, }, isError: true, }; }
const before = await file.text();
// Find occurrences of oldText. Use indexOf from successive offsets. const first = before.indexOf(input.oldText); if (first === -1) { return { content: { kind: 'error', message: `edit ${input.path}: oldText not found`, }, isError: true, }; } const second = before.indexOf(input.oldText, first + 1); if (second !== -1) { return { content: { kind: 'error', message: `edit ${input.path}: oldText matches more than once (ambiguous) — include more surrounding context so the match is unique`, }, isError: true, }; }
// Exactly one match — perform the replacement. const after = before.slice(0, first) + input.newText + before.slice(first + input.oldText.length);
await Bun.write(filePath, after);
const hunk = computeDiffHunk(before, after); return { content: { kind: 'text', text: `edited ${input.path}`, }, isError: false, render: { kind: 'diff', path: input.path, hunks: [hunk] }, // duoh76.6: absolute path for the FileMutationNotifier. Error/early-return paths // don't set this (no mutation happened). mutatedFiles: [filePath], }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { content: { kind: 'error', message: `Failed to edit ${filePath}: ${message}` }, isError: true, }; } },
onStart(input: EditInput): RenderDescriptor { return { kind: 'status', label: `editing ${input.path}`, tone: 'info' }; },
onResult(input: EditInput, result: ToolResult): RenderDescriptor { if (result.isError) { const content = result.content as { kind: 'error'; message: string }; return { kind: 'status', label: `error editing ${input.path}: ${content.message}`, tone: 'error', }; } // Prefer the diff computed at execute time; fall back to a plain status. if (result.render?.kind === 'diff') { return result.render; } return { kind: 'status', label: `edited ${input.path}`, tone: 'info' }; },};