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) Name() string {
91 return WriteToolName
92}
93
94func (w *writeTool) Info() ToolInfo {
95 return ToolInfo{
96 Name: WriteToolName,
97 Description: writeDescription,
98 Parameters: map[string]any{
99 "file_path": map[string]any{
100 "type": "string",
101 "description": "The path to the file to write",
102 },
103 "content": map[string]any{
104 "type": "string",
105 "description": "The content to write to the file",
106 },
107 },
108 Required: []string{"file_path", "content"},
109 }
110}
111
112func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
113 var params WriteParams
114 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
115 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
116 }
117
118 if params.FilePath == "" {
119 return NewTextErrorResponse("file_path is required"), nil
120 }
121
122 if params.Content == "" {
123 return NewTextErrorResponse("content is required"), nil
124 }
125
126 filePath := params.FilePath
127 if !filepath.IsAbs(filePath) {
128 filePath = filepath.Join(w.workingDir, filePath)
129 }
130
131 fileInfo, err := os.Stat(filePath)
132 if err == nil {
133 if fileInfo.IsDir() {
134 return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
135 }
136
137 modTime := fileInfo.ModTime()
138 lastRead := getLastReadTime(filePath)
139 if modTime.After(lastRead) {
140 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.",
141 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
142 }
143
144 oldContent, readErr := os.ReadFile(filePath)
145 if readErr == nil && string(oldContent) == params.Content {
146 return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
147 }
148 } else if !os.IsNotExist(err) {
149 return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
150 }
151
152 dir := filepath.Dir(filePath)
153 if err = os.MkdirAll(dir, 0o755); err != nil {
154 return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
155 }
156
157 oldContent := ""
158 if fileInfo != nil && !fileInfo.IsDir() {
159 oldBytes, readErr := os.ReadFile(filePath)
160 if readErr == nil {
161 oldContent = string(oldBytes)
162 }
163 }
164
165 sessionID, messageID := GetContextValues(ctx)
166 if sessionID == "" || messageID == "" {
167 return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
168 }
169
170 diff, additions, removals := diff.GenerateDiff(
171 oldContent,
172 params.Content,
173 strings.TrimPrefix(filePath, w.workingDir),
174 )
175
176 p := w.permissions.Request(
177 permission.CreatePermissionRequest{
178 SessionID: sessionID,
179 Path: fsext.PathOrPrefix(filePath, w.workingDir),
180 ToolCallID: call.ID,
181 ToolName: WriteToolName,
182 Action: "write",
183 Description: fmt.Sprintf("Create file %s", filePath),
184 Params: WritePermissionsParams{
185 FilePath: filePath,
186 OldContent: oldContent,
187 NewContent: params.Content,
188 },
189 },
190 )
191 if !p {
192 return ToolResponse{}, permission.ErrorPermissionDenied
193 }
194
195 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
196 if err != nil {
197 return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
198 }
199
200 // Check if file exists in history
201 file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
202 if err != nil {
203 _, err = w.files.Create(ctx, sessionID, filePath, oldContent)
204 if err != nil {
205 // Log error but don't fail the operation
206 return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
207 }
208 }
209 if file.Content != oldContent {
210 // User Manually changed the content store an intermediate version
211 _, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
212 if err != nil {
213 slog.Debug("Error creating file history version", "error", err)
214 }
215 }
216 // Store the new version
217 _, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
218 if err != nil {
219 slog.Debug("Error creating file history version", "error", err)
220 }
221
222 recordFileWrite(filePath)
223 recordFileRead(filePath)
224 waitForLspDiagnostics(ctx, filePath, w.lspClients)
225
226 result := fmt.Sprintf("File successfully written: %s", filePath)
227 result = fmt.Sprintf("<result>\n%s\n</result>", result)
228 result += getDiagnostics(filePath, w.lspClients)
229 return WithResponseMetadata(NewTextResponse(result),
230 WriteResponseMetadata{
231 Diff: diff,
232 Additions: additions,
233 Removals: removals,
234 },
235 ), nil
236}