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