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