write.go

  1package tools
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10	"time"
 11
 12	"github.com/opencode-ai/opencode/internal/config"
 13	"github.com/opencode-ai/opencode/internal/diff"
 14	"github.com/opencode-ai/opencode/internal/history"
 15	"github.com/opencode-ai/opencode/internal/logging"
 16	"github.com/opencode-ai/opencode/internal/lsp"
 17	"github.com/opencode-ai/opencode/internal/permission"
 18)
 19
 20type WriteParams struct {
 21	FilePath string `json:"file_path"`
 22	Content  string `json:"content"`
 23}
 24
 25type WritePermissionsParams struct {
 26	FilePath string `json:"file_path"`
 27	Diff     string `json:"diff"`
 28}
 29
 30type writeTool struct {
 31	lspClients  map[string]*lsp.Client
 32	permissions permission.Service
 33	files       history.Service
 34}
 35
 36type WriteResponseMetadata struct {
 37	Diff      string `json:"diff"`
 38	Additions int    `json:"additions"`
 39	Removals  int    `json:"removals"`
 40}
 41
 42const (
 43	WriteToolName    = "write"
 44	writeDescription = `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
 66
 67TIPS:
 68- Use the View tool first to examine existing files before modifying them
 69- Use the LS tool to verify the correct location when creating new files
 70- Combine with Glob and Grep tools to find and modify multiple files
 71- Always include descriptive comments when making changes to existing code`
 72)
 73
 74func NewWriteTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
 75	return &writeTool{
 76		lspClients:  lspClients,
 77		permissions: permissions,
 78		files:       files,
 79	}
 80}
 81
 82func (w *writeTool) Info() ToolInfo {
 83	return ToolInfo{
 84		Name:        WriteToolName,
 85		Description: writeDescription,
 86		Parameters: map[string]any{
 87			"file_path": map[string]any{
 88				"type":        "string",
 89				"description": "The path to the file to write",
 90			},
 91			"content": map[string]any{
 92				"type":        "string",
 93				"description": "The content to write to the file",
 94			},
 95		},
 96		Required: []string{"file_path", "content"},
 97	}
 98}
 99
100func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
101	var params WriteParams
102	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
103		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
104	}
105
106	if params.FilePath == "" {
107		return NewTextErrorResponse("file_path is required"), nil
108	}
109
110	if params.Content == "" {
111		return NewTextErrorResponse("content is required"), nil
112	}
113
114	filePath := params.FilePath
115	if !filepath.IsAbs(filePath) {
116		filePath = filepath.Join(config.WorkingDirectory(), filePath)
117	}
118
119	fileInfo, err := os.Stat(filePath)
120	if err == nil {
121		if fileInfo.IsDir() {
122			return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
123		}
124
125		modTime := fileInfo.ModTime()
126		lastRead := getLastReadTime(filePath)
127		if modTime.After(lastRead) {
128			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.",
129				filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
130		}
131
132		oldContent, readErr := os.ReadFile(filePath)
133		if readErr == nil && string(oldContent) == params.Content {
134			return NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
135		}
136	} else if !os.IsNotExist(err) {
137		return ToolResponse{}, fmt.Errorf("error checking file: %w", err)
138	}
139
140	dir := filepath.Dir(filePath)
141	if err = os.MkdirAll(dir, 0o755); err != nil {
142		return ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
143	}
144
145	oldContent := ""
146	if fileInfo != nil && !fileInfo.IsDir() {
147		oldBytes, readErr := os.ReadFile(filePath)
148		if readErr == nil {
149			oldContent = string(oldBytes)
150		}
151	}
152
153	sessionID, messageID := GetContextValues(ctx)
154	if sessionID == "" || messageID == "" {
155		return ToolResponse{}, fmt.Errorf("session_id and message_id are required")
156	}
157
158	diff, additions, removals := diff.GenerateDiff(
159		oldContent,
160		params.Content,
161		filePath,
162	)
163
164	rootDir := config.WorkingDirectory()
165	permissionPath := filepath.Dir(filePath)
166	if strings.HasPrefix(filePath, rootDir) {
167		permissionPath = rootDir
168	}
169	p := w.permissions.Request(
170		permission.CreatePermissionRequest{
171			SessionID:   sessionID,
172			Path:        permissionPath,
173			ToolName:    WriteToolName,
174			Action:      "write",
175			Description: fmt.Sprintf("Create file %s", filePath),
176			Params: WritePermissionsParams{
177				FilePath: filePath,
178				Diff:     diff,
179			},
180		},
181	)
182	if !p {
183		return ToolResponse{}, permission.ErrorPermissionDenied
184	}
185
186	err = os.WriteFile(filePath, []byte(params.Content), 0o644)
187	if err != nil {
188		return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
189	}
190
191	// Check if file exists in history
192	file, err := w.files.GetByPathAndSession(ctx, filePath, sessionID)
193	if err != nil {
194		_, err = w.files.Create(ctx, sessionID, filePath, oldContent)
195		if err != nil {
196			// Log error but don't fail the operation
197			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
198		}
199	}
200	if file.Content != oldContent {
201		// User Manually changed the content store an intermediate version
202		_, err = w.files.CreateVersion(ctx, sessionID, filePath, oldContent)
203		if err != nil {
204			logging.Debug("Error creating file history version", "error", err)
205		}
206	}
207	// Store the new version
208	_, err = w.files.CreateVersion(ctx, sessionID, filePath, params.Content)
209	if err != nil {
210		logging.Debug("Error creating file history version", "error", err)
211	}
212
213	recordFileWrite(filePath)
214	recordFileRead(filePath)
215	waitForLspDiagnostics(ctx, filePath, w.lspClients)
216
217	result := fmt.Sprintf("File successfully written: %s", filePath)
218	result = fmt.Sprintf("<result>\n%s\n</result>", result)
219	result += getDiagnostics(filePath, w.lspClients)
220	return WithResponseMetadata(NewTextResponse(result),
221		WriteResponseMetadata{
222			Diff:      diff,
223			Additions: additions,
224			Removals:  removals,
225		},
226	), nil
227}