2
0

write.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. package tools
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "os"
  7. "path/filepath"
  8. "strings"
  9. "time"
  10. "github.com/charmbracelet/crush/internal/config"
  11. "github.com/charmbracelet/crush/internal/diff"
  12. "github.com/charmbracelet/crush/internal/history"
  13. "github.com/charmbracelet/crush/internal/logging"
  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. }
  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. TIPS:
  56. - Use the View tool first to examine existing files before modifying them
  57. - Use the LS tool to verify the correct location when creating new files
  58. - Combine with Glob and Grep tools to find and modify multiple files
  59. - Always include descriptive comments when making changes to existing code`
  60. )
  61. func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
  62. return &writeTool{
  63. lspClients: lspClients,
  64. permissions: permissions,
  65. files: files,
  66. }
  67. }
  68. func (w *writeTool) Info() ToolInfo {
  69. return ToolInfo{
  70. Name: WriteToolName,
  71. Description: writeDescription,
  72. Parameters: map[string]any{
  73. "file_path": map[string]any{
  74. "type": "string",
  75. "description": "The path to the file to write",
  76. },
  77. "content": map[string]any{
  78. "type": "string",
  79. "description": "The content to write to the file",
  80. },
  81. },
  82. Required: []string{"file_path", "content"},
  83. }
  84. }
  85. func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
  86. var params WriteParams
  87. if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
  88. return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
  89. }
  90. if params.FilePath == "" {
  91. return NewTextErrorResponse("file_path is required"), nil
  92. }
  93. if params.Content == "" {
  94. return NewTextErrorResponse("content is required"), nil
  95. }
  96. filePath := params.FilePath
  97. if !filepath.IsAbs(filePath) {
  98. filePath = filepath.Join(config.WorkingDirectory(), filePath)
  99. }
  100. fileInfo, err := os.Stat(filePath)
  101. if err == nil {
  102. if fileInfo.IsDir() {
  103. return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
  104. }
  105. modTime := fileInfo.ModTime()
  106. lastRead := getLastReadTime(filePath)
  107. if modTime.After(lastRead) {
  108. 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.",
  109. filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
  110. }
  111. oldContent, readErr := os.ReadFile(filePath)
  112. if readErr == nil && string(oldContent) == params.Content {
  113. return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
  114. }
  115. } else if !os.IsNotExist(err) {
  116. return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
  117. }
  118. dir := filepath.Dir(filePath)
  119. if err = os.MkdirAll(dir, 0o755); err != nil {
  120. return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
  121. }
  122. oldContent := ""
  123. if fileInfo != nil && !fileInfo.IsDir() {
  124. oldBytes, readErr := os.ReadFile(filePath)
  125. if readErr == nil {
  126. oldContent = string(oldBytes)
  127. }
  128. }
  129. sessionID, messageID := GetContextValues(ctx)
  130. if sessionID == "" || messageID == "" {
  131. return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
  132. }
  133. diff, additions, removals := diff.GenerateDiff(
  134. oldContent,
  135. params.Content,
  136. filePath,
  137. )
  138. rootDir := config.WorkingDirectory()
  139. permissionPath := filepath.Dir(filePath)
  140. if strings.HasPrefix(filePath, rootDir) {
  141. permissionPath = rootDir
  142. }
  143. p := w.permissions.Request(
  144. permission.CreatePermissionRequest{
  145. SessionID: sessionID,
  146. Path: permissionPath,
  147. ToolName: WriteToolName,
  148. Action: "write",
  149. Description: fmt.Sprintf("Create file %s", filePath),
  150. Params: WritePermissionsParams{
  151. FilePath: filePath,
  152. OldContent: oldContent,
  153. NewContent: params.Content,
  154. },
  155. },
  156. )
  157. if !p {
  158. return ToolResponse{}, permission.ErrorPermissionDenied
  159. }
  160. err = os.WriteFile(filePath, []byte(params.Content), 0o644)
  161. if err != nil {
  162. return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
  163. }
  164. // Check if file exists in history
  165. file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
  166. if err != nil {
  167. _, err = w.files.Create(ctx, sessionID, filePath, oldContent)
  168. if err != nil {
  169. // Log error but don't fail the operation
  170. return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
  171. }
  172. }
  173. if file.Content != oldContent {
  174. // User Manually changed the content store an intermediate version
  175. _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
  176. if err != nil {
  177. logging.Debug("Error creating file history version", "error", err)
  178. }
  179. }
  180. // Store the new version
  181. _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
  182. if err != nil {
  183. logging.Debug("Error creating file history version", "error", err)
  184. }
  185. recordFileWrite(filePath)
  186. recordFileRead(filePath)
  187. waitForLspDiagnostics(ctx, filePath, w.lspClients)
  188. result := fmt.Sprintf("File successfully written: %s", filePath)
  189. result = fmt.Sprintf("<result>\n%s\n</result>", result)
  190. result += getDiagnostics(filePath, w.lspClients)
  191. return WithResponseMetadata(NewTextResponse(result),
  192. WriteResponseMetadata{
  193. Diff: diff,
  194. Additions: additions,
  195. Removals: removals,
  196. },
  197. ), nil
  198. }