write.go

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