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