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