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