write.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. package tools
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "os"
  8. "path/filepath"
  9. "strings"
  10. "time"
  11. "github.com/charmbracelet/crush/internal/diff"
  12. "github.com/charmbracelet/crush/internal/fsext"
  13. "github.com/charmbracelet/crush/internal/history"
  14. "github.com/charmbracelet/crush/internal/lsp"
  15. "github.com/charmbracelet/crush/internal/permission"
  16. )
  17. type WriteParams struct {
  18. FilePath string `json:"file_path"`
  19. Content string `json:"content"`
  20. }
  21. type WritePermissionsParams struct {
  22. FilePath string `json:"file_path"`
  23. OldContent string `json:"old_content,omitempty"`
  24. NewContent string `json:"new_content,omitempty"`
  25. }
  26. type writeTool struct {
  27. lspClients map[string]*lsp.Client
  28. permissions permission.Service
  29. files history.Service
  30. workingDir string
  31. }
  32. type WriteResponseMetadata struct {
  33. Diff string `json:"diff"`
  34. Additions int `json:"additions"`
  35. Removals int `json:"removals"`
  36. }
  37. const (
  38. WriteToolName = "write"
  39. writeDescription = `File writing tool that creates or updates files in the filesystem, allowing you to save or modify text content.
  40. WHEN TO USE THIS TOOL:
  41. - Use when you need to create a new file
  42. - Helpful for updating existing files with modified content
  43. - Perfect for saving generated code, configurations, or text data
  44. HOW TO USE:
  45. - Provide the path to the file you want to write
  46. - Include the content to be written to the file
  47. - The tool will create any necessary parent directories
  48. FEATURES:
  49. - Can create new files or overwrite existing ones
  50. - Creates parent directories automatically if they don't exist
  51. - Checks if the file has been modified since last read for safety
  52. - Avoids unnecessary writes when content hasn't changed
  53. LIMITATIONS:
  54. - You should read a file before writing to it to avoid conflicts
  55. - Cannot append to files (rewrites the entire file)
  56. WINDOWS NOTES:
  57. - File permissions (0o755, 0o644) are Unix-style but work on Windows with appropriate translations
  58. - Use forward slashes (/) in paths for cross-platform compatibility
  59. - Windows file attributes and permissions are handled automatically by the Go runtime
  60. TIPS:
  61. - Use the View tool first to examine existing files before modifying them
  62. - Use the LS tool to verify the correct location when creating new files
  63. - Combine with Glob and Grep tools to find and modify multiple files
  64. - Always include descriptive comments when making changes to existing code`
  65. )
  66. func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service, workingDir string) BaseTool {
  67. return &writeTool{
  68. lspClients: lspClients,
  69. permissions: permissions,
  70. files: files,
  71. workingDir: workingDir,
  72. }
  73. }
  74. func (w *writeTool) Name() string {
  75. return WriteToolName
  76. }
  77. func (w *writeTool) Info() ToolInfo {
  78. return ToolInfo{
  79. Name: WriteToolName,
  80. Description: writeDescription,
  81. Parameters: map[string]any{
  82. "file_path": map[string]any{
  83. "type": "string",
  84. "description": "The path to the file to write",
  85. },
  86. "content": map[string]any{
  87. "type": "string",
  88. "description": "The content to write to the file",
  89. },
  90. },
  91. Required: []string{"file_path", "content"},
  92. }
  93. }
  94. func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
  95. var params WriteParams
  96. if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
  97. return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
  98. }
  99. if params.FilePath == "" {
  100. return NewTextErrorResponse("file_path is required"), nil
  101. }
  102. if params.Content == "" {
  103. return NewTextErrorResponse("content is required"), nil
  104. }
  105. filePath := params.FilePath
  106. if !filepath.IsAbs(filePath) {
  107. filePath = filepath.Join(w.workingDir, filePath)
  108. }
  109. fileInfo, err := os.Stat(filePath)
  110. if err == nil {
  111. if fileInfo.IsDir() {
  112. return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
  113. }
  114. modTime := fileInfo.ModTime()
  115. lastRead := getLastReadTime(filePath)
  116. if modTime.After(lastRead) {
  117. return NewTextErrorResponse(fmt.Sprintf("File %s has been modified since it was last read.\nLast modification: %s\nLast read: %s\n\nPlease read the file again before modifying it.",
  118. filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
  119. }
  120. oldContent, readErr := os.ReadFile(filePath)
  121. if readErr == nil && string(oldContent) == params.Content {
  122. return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
  123. }
  124. } else if !os.IsNotExist(err) {
  125. return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
  126. }
  127. dir := filepath.Dir(filePath)
  128. if err = os.MkdirAll(dir, 0o755); err != nil {
  129. return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
  130. }
  131. oldContent := ""
  132. if fileInfo != nil && !fileInfo.IsDir() {
  133. oldBytes, readErr := os.ReadFile(filePath)
  134. if readErr == nil {
  135. oldContent = string(oldBytes)
  136. }
  137. }
  138. sessionID, messageID := GetContextValues(ctx)
  139. if sessionID == "" || messageID == "" {
  140. return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
  141. }
  142. diff, additions, removals := diff.GenerateDiff(
  143. oldContent,
  144. params.Content,
  145. strings.TrimPrefix(filePath, w.workingDir),
  146. )
  147. p := w.permissions.Request(
  148. permission.CreatePermissionRequest{
  149. SessionID: sessionID,
  150. Path: fsext.PathOrPrefix(filePath, w.workingDir),
  151. ToolCallID: call.ID,
  152. ToolName: WriteToolName,
  153. Action: "write",
  154. Description: fmt.Sprintf("Create file %s", filePath),
  155. Params: WritePermissionsParams{
  156. FilePath: filePath,
  157. OldContent: oldContent,
  158. NewContent: params.Content,
  159. },
  160. },
  161. )
  162. if !p {
  163. return ToolResponse{}, permission.ErrorPermissionDenied
  164. }
  165. err = os.WriteFile(filePath, []byte(params.Content), 0o644)
  166. if err != nil {
  167. return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
  168. }
  169. // Check if file exists in history
  170. file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
  171. if err != nil {
  172. _, err = w.files.Create(ctx, sessionID, filePath, oldContent)
  173. if err != nil {
  174. // Log error but don't fail the operation
  175. return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
  176. }
  177. }
  178. if file.Content != oldContent {
  179. // User Manually changed the content store an intermediate version
  180. _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
  181. if err != nil {
  182. slog.Debug("Error creating file history version", "error", err)
  183. }
  184. }
  185. // Store the new version
  186. _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
  187. if err != nil {
  188. slog.Debug("Error creating file history version", "error", err)
  189. }
  190. recordFileWrite(filePath)
  191. recordFileRead(filePath)
  192. waitForLspDiagnostics(ctx, filePath, w.lspClients)
  193. result := fmt.Sprintf("File successfully written: %s", filePath)
  194. result = fmt.Sprintf("<result>\n%s\n</result>", result)
  195. result += getDiagnostics(filePath, w.lspClients)
  196. return WithResponseMetadata(NewTextResponse(result),
  197. WriteResponseMetadata{
  198. Diff: diff,
  199. Additions: additions,
  200. Removals: removals,
  201. },
  202. ), nil
  203. }