edit.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. package tools
  2. import (
  3. "context"
  4. _ "embed"
  5. "fmt"
  6. "log/slog"
  7. "os"
  8. "path/filepath"
  9. "strings"
  10. "time"
  11. "charm.land/fantasy"
  12. "github.com/charmbracelet/crush/internal/csync"
  13. "github.com/charmbracelet/crush/internal/diff"
  14. "github.com/charmbracelet/crush/internal/filepathext"
  15. "github.com/charmbracelet/crush/internal/fsext"
  16. "github.com/charmbracelet/crush/internal/history"
  17. "github.com/charmbracelet/crush/internal/lsp"
  18. "github.com/charmbracelet/crush/internal/permission"
  19. )
  20. type EditParams struct {
  21. FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
  22. OldString string `json:"old_string" description:"The text to replace"`
  23. NewString string `json:"new_string" description:"The text to replace it with"`
  24. ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
  25. }
  26. type EditPermissionsParams struct {
  27. FilePath string `json:"file_path"`
  28. OldContent string `json:"old_content,omitempty"`
  29. NewContent string `json:"new_content,omitempty"`
  30. }
  31. type EditResponseMetadata struct {
  32. Additions int `json:"additions"`
  33. Removals int `json:"removals"`
  34. OldContent string `json:"old_content,omitempty"`
  35. NewContent string `json:"new_content,omitempty"`
  36. }
  37. const EditToolName = "edit"
  38. //go:embed edit.md
  39. var editDescription []byte
  40. type editContext struct {
  41. ctx context.Context
  42. permissions permission.Service
  43. files history.Service
  44. workingDir string
  45. }
  46. func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
  47. return fantasy.NewAgentTool(
  48. EditToolName,
  49. string(editDescription),
  50. func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  51. if params.FilePath == "" {
  52. return fantasy.NewTextErrorResponse("file_path is required"), nil
  53. }
  54. params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
  55. var response fantasy.ToolResponse
  56. var err error
  57. editCtx := editContext{ctx, permissions, files, workingDir}
  58. if params.OldString == "" {
  59. response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
  60. } else if params.NewString == "" {
  61. response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
  62. } else {
  63. response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
  64. }
  65. if err != nil {
  66. return response, err
  67. }
  68. if response.IsError {
  69. // Return early if there was an error during content replacement
  70. // This prevents unnecessary LSP diagnostics processing
  71. return response, nil
  72. }
  73. notifyLSPs(ctx, lspClients, params.FilePath)
  74. text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
  75. text += getDiagnostics(params.FilePath, lspClients)
  76. response.Content = text
  77. return response, nil
  78. })
  79. }
  80. func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  81. fileInfo, err := os.Stat(filePath)
  82. if err == nil {
  83. if fileInfo.IsDir() {
  84. return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
  85. }
  86. return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
  87. } else if !os.IsNotExist(err) {
  88. return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
  89. }
  90. dir := filepath.Dir(filePath)
  91. if err = os.MkdirAll(dir, 0o755); err != nil {
  92. return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
  93. }
  94. sessionID := GetSessionFromContext(edit.ctx)
  95. if sessionID == "" {
  96. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
  97. }
  98. _, additions, removals := diff.GenerateDiff(
  99. "",
  100. content,
  101. strings.TrimPrefix(filePath, edit.workingDir),
  102. )
  103. p := edit.permissions.Request(
  104. permission.CreatePermissionRequest{
  105. SessionID: sessionID,
  106. Path: fsext.PathOrPrefix(filePath, edit.workingDir),
  107. ToolCallID: call.ID,
  108. ToolName: EditToolName,
  109. Action: "write",
  110. Description: fmt.Sprintf("Create file %s", filePath),
  111. Params: EditPermissionsParams{
  112. FilePath: filePath,
  113. OldContent: "",
  114. NewContent: content,
  115. },
  116. },
  117. )
  118. if !p {
  119. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  120. }
  121. err = os.WriteFile(filePath, []byte(content), 0o644)
  122. if err != nil {
  123. return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
  124. }
  125. // File can't be in the history so we create a new file history
  126. _, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
  127. if err != nil {
  128. // Log error but don't fail the operation
  129. return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
  130. }
  131. // Add the new content to the file history
  132. _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
  133. if err != nil {
  134. // Log error but don't fail the operation
  135. slog.Error("Error creating file history version", "error", err)
  136. }
  137. recordFileWrite(filePath)
  138. recordFileRead(filePath)
  139. return fantasy.WithResponseMetadata(
  140. fantasy.NewTextResponse("File created: "+filePath),
  141. EditResponseMetadata{
  142. OldContent: "",
  143. NewContent: content,
  144. Additions: additions,
  145. Removals: removals,
  146. },
  147. ), nil
  148. }
  149. func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  150. fileInfo, err := os.Stat(filePath)
  151. if err != nil {
  152. if os.IsNotExist(err) {
  153. return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
  154. }
  155. return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
  156. }
  157. if fileInfo.IsDir() {
  158. return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
  159. }
  160. if getLastReadTime(filePath).IsZero() {
  161. return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
  162. }
  163. modTime := fileInfo.ModTime()
  164. lastRead := getLastReadTime(filePath)
  165. if modTime.After(lastRead) {
  166. return fantasy.NewTextErrorResponse(
  167. fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
  168. filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
  169. )), nil
  170. }
  171. content, err := os.ReadFile(filePath)
  172. if err != nil {
  173. return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
  174. }
  175. oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
  176. var newContent string
  177. var deletionCount int
  178. if replaceAll {
  179. newContent = strings.ReplaceAll(oldContent, oldString, "")
  180. deletionCount = strings.Count(oldContent, oldString)
  181. if deletionCount == 0 {
  182. return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
  183. }
  184. } else {
  185. index := strings.Index(oldContent, oldString)
  186. if index == -1 {
  187. return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
  188. }
  189. lastIndex := strings.LastIndex(oldContent, oldString)
  190. if index != lastIndex {
  191. return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
  192. }
  193. newContent = oldContent[:index] + oldContent[index+len(oldString):]
  194. deletionCount = 1
  195. }
  196. sessionID := GetSessionFromContext(edit.ctx)
  197. if sessionID == "" {
  198. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
  199. }
  200. _, additions, removals := diff.GenerateDiff(
  201. oldContent,
  202. newContent,
  203. strings.TrimPrefix(filePath, edit.workingDir),
  204. )
  205. p := edit.permissions.Request(
  206. permission.CreatePermissionRequest{
  207. SessionID: sessionID,
  208. Path: fsext.PathOrPrefix(filePath, edit.workingDir),
  209. ToolCallID: call.ID,
  210. ToolName: EditToolName,
  211. Action: "write",
  212. Description: fmt.Sprintf("Delete content from file %s", filePath),
  213. Params: EditPermissionsParams{
  214. FilePath: filePath,
  215. OldContent: oldContent,
  216. NewContent: newContent,
  217. },
  218. },
  219. )
  220. if !p {
  221. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  222. }
  223. if isCrlf {
  224. newContent, _ = fsext.ToWindowsLineEndings(newContent)
  225. }
  226. err = os.WriteFile(filePath, []byte(newContent), 0o644)
  227. if err != nil {
  228. return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
  229. }
  230. // Check if file exists in history
  231. file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
  232. if err != nil {
  233. _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
  234. if err != nil {
  235. // Log error but don't fail the operation
  236. return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
  237. }
  238. }
  239. if file.Content != oldContent {
  240. // User Manually changed the content store an intermediate version
  241. _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
  242. if err != nil {
  243. slog.Error("Error creating file history version", "error", err)
  244. }
  245. }
  246. // Store the new version
  247. _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, "")
  248. if err != nil {
  249. slog.Error("Error creating file history version", "error", err)
  250. }
  251. recordFileWrite(filePath)
  252. recordFileRead(filePath)
  253. return fantasy.WithResponseMetadata(
  254. fantasy.NewTextResponse("Content deleted from file: "+filePath),
  255. EditResponseMetadata{
  256. OldContent: oldContent,
  257. NewContent: newContent,
  258. Additions: additions,
  259. Removals: removals,
  260. },
  261. ), nil
  262. }
  263. func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  264. fileInfo, err := os.Stat(filePath)
  265. if err != nil {
  266. if os.IsNotExist(err) {
  267. return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
  268. }
  269. return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
  270. }
  271. if fileInfo.IsDir() {
  272. return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
  273. }
  274. if getLastReadTime(filePath).IsZero() {
  275. return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
  276. }
  277. modTime := fileInfo.ModTime()
  278. lastRead := getLastReadTime(filePath)
  279. if modTime.After(lastRead) {
  280. return fantasy.NewTextErrorResponse(
  281. fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
  282. filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
  283. )), nil
  284. }
  285. content, err := os.ReadFile(filePath)
  286. if err != nil {
  287. return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
  288. }
  289. oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
  290. var newContent string
  291. var replacementCount int
  292. if replaceAll {
  293. newContent = strings.ReplaceAll(oldContent, oldString, newString)
  294. replacementCount = strings.Count(oldContent, oldString)
  295. if replacementCount == 0 {
  296. return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
  297. }
  298. } else {
  299. index := strings.Index(oldContent, oldString)
  300. if index == -1 {
  301. return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
  302. }
  303. lastIndex := strings.LastIndex(oldContent, oldString)
  304. if index != lastIndex {
  305. return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
  306. }
  307. newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
  308. replacementCount = 1
  309. }
  310. if oldContent == newContent {
  311. return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
  312. }
  313. sessionID := GetSessionFromContext(edit.ctx)
  314. if sessionID == "" {
  315. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
  316. }
  317. _, additions, removals := diff.GenerateDiff(
  318. oldContent,
  319. newContent,
  320. strings.TrimPrefix(filePath, edit.workingDir),
  321. )
  322. p := edit.permissions.Request(
  323. permission.CreatePermissionRequest{
  324. SessionID: sessionID,
  325. Path: fsext.PathOrPrefix(filePath, edit.workingDir),
  326. ToolCallID: call.ID,
  327. ToolName: EditToolName,
  328. Action: "write",
  329. Description: fmt.Sprintf("Replace content in file %s", filePath),
  330. Params: EditPermissionsParams{
  331. FilePath: filePath,
  332. OldContent: oldContent,
  333. NewContent: newContent,
  334. },
  335. },
  336. )
  337. if !p {
  338. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  339. }
  340. if isCrlf {
  341. newContent, _ = fsext.ToWindowsLineEndings(newContent)
  342. }
  343. err = os.WriteFile(filePath, []byte(newContent), 0o644)
  344. if err != nil {
  345. return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
  346. }
  347. // Check if file exists in history
  348. file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
  349. if err != nil {
  350. _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
  351. if err != nil {
  352. // Log error but don't fail the operation
  353. return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
  354. }
  355. }
  356. if file.Content != oldContent {
  357. // User Manually changed the content store an intermediate version
  358. _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
  359. if err != nil {
  360. slog.Debug("Error creating file history version", "error", err)
  361. }
  362. }
  363. // Store the new version
  364. _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
  365. if err != nil {
  366. slog.Error("Error creating file history version", "error", err)
  367. }
  368. recordFileWrite(filePath)
  369. recordFileRead(filePath)
  370. return fantasy.WithResponseMetadata(
  371. fantasy.NewTextResponse("Content replaced in file: "+filePath),
  372. EditResponseMetadata{
  373. OldContent: oldContent,
  374. NewContent: newContent,
  375. Additions: additions,
  376. Removals: removals,
  377. }), nil
  378. }