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