write.go 6.9 KB

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