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