write.go

  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), &params); 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
225	notifyLSPs(ctx, w.lspClients, params.FilePath)
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}