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