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