write.go

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