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/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), &params); 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}