write.go 6.3 KB

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