write.go 6.4 KB

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