opencode/packages/core/src/tool/plugin/edit.ts
2026-07-27 14:58:51 +00:00

217 lines
9.4 KiB
TypeScript

/**
* Model-facing exact-edit leaf. Relative paths resolve within the active
* Location. Absolute paths inside that Location are accepted, while explicit
* absolute external paths retain mutation capability through a separate
* external_directory approval before edit approval.
*/
export * as EditTool from "./edit"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { FileMutation } from "../../file-mutation"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { KeyedMutex } from "../../effect/keyed-mutex"
import { replace } from "./edit-replace"
export const name = "edit"
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
}),
oldString: Schema.String.annotate({ description: "The text to replace" }),
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
replaceAll: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Replace all exact occurrences of oldString (default false)",
}),
})
export const Output = Schema.Struct({
files: Schema.Array(FileDiff.Info),
replacements: Schema.Number,
})
export type Output = typeof Output.Type
const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
const convertToLineEnding = (text: string, ending: "\n" | "\r\n") =>
ending === "\n" ? normalizeLineEndings(text) : normalizeLineEndings(text).replaceAll("\n", "\r\n")
const splitBom = (text: string) =>
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const joinBom = (text: string, bom: boolean) => (bom ? `\uFEFF${text}` : text)
const decodeUtf8 = (content: Uint8Array) => {
const bom = content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
return { bom, content, text: new TextDecoder().decode(bom ? content.slice(3) : content) }
}
const countOccurrences = (content: string, search: string) => {
let count = 0
let offset = 0
while ((offset = content.indexOf(search, offset)) !== -1) {
count++
offset += search.length
}
return count
}
const previewLines = (value: string, prefix: "+" | "-") => {
const lines = normalizeLineEndings(value).split("\n")
const shown = lines.slice(0, 6).map((line) => `${prefix}${line.length > 240 ? `${line.slice(0, 240)}...` : line}`)
if (lines.length > shown.length) shown.push(`${prefix}...`)
return shown
}
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
[
`Edited file successfully: ${output.files[0]?.file}`,
`Replacements: ${output.replacements}`,
"```diff",
...previewLines(oldString, "-"),
...previewLines(newString, "+"),
"```",
].join("\n")
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after design exists.
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
const locks = KeyedMutex.makeUnsafe<string>()
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false, permission: "edit" },
description:
"Replace text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
execute: (input, context) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.mapError((error) => new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
)
return Effect.gen(function* () {
const permissionSource = {
type: "tool" as const,
messageID: context.messageID,
callID: context.callID,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({
message: "No changes to apply: oldString and newString are identical.",
})
}
const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" }))
const external = target.externalDirectory
if (external) {
yield* unableToEdit(
permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
}),
)
}
return yield* locks.withLock(target.canonical)(
Effect.gen(function* () {
const content = yield* fs.readFile(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
unableToEdit,
)
if (input.oldString === "" && content !== undefined) {
return yield* new ToolFailure({
message:
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
})
}
if (content === undefined && input.oldString !== "") {
return yield* new ToolFailure({ message: `File ${input.path} not found` })
}
const source = decodeUtf8(content ?? new Uint8Array())
const ending = detectLineEnding(source.text)
const oldString = convertToLineEnding(input.oldString, ending)
const newString = convertToLineEnding(input.newString, ending)
const replaced =
input.oldString === ""
? newString
: yield* Effect.try({
try: () => replace(source.text, oldString, newString, input.replaceAll),
catch: (error) =>
new ToolFailure({
message: error instanceof Error ? error.message : `Unable to edit ${input.path}`,
}),
})
const replacements =
input.oldString === "" ? 1 : Math.max(1, countOccurrences(source.text, oldString))
const counts = diffLines(source.text, replaced).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
const next = splitBom(replaced)
const patch = createTwoFilesPatch(target.resource, target.resource, source.text, replaced)
yield* unableToEdit(
permission.assert({
action: "edit",
resources: [target.resource],
save: ["*"],
metadata: { filepath: target.canonical, diff: patch },
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
}),
)
const result = yield* unableToEdit(
files.write({
target,
content: joinBom(next.text, source.bom || next.bom),
}),
)
return {
files: [
{
file: result.resource,
patch,
status: "modified" as const,
...counts,
},
],
replacements,
} satisfies Output
}),
)
}).pipe(
Effect.map((output) => ({
output,
content: toModelOutput(output, input.oldString, input.newString),
metadata: { files: output.files },
})),
)
},
}),
)
.pipe(Effect.orDie)
}),
}