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