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