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