write.go

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