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), ¶ms); 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}