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/diff"
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), ¶ms); 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
153 diff, additions, removals := diff.GenerateDiff(
154 oldContent,
155 params.Content,
156 filePath,
157 )
158 p := w.permissions.Request(
159 permission.CreatePermissionRequest{
160 Path: filePath,
161 ToolName: WriteToolName,
162 Action: "create",
163 Description: fmt.Sprintf("Create file %s", filePath),
164 Params: WritePermissionsParams{
165 FilePath: filePath,
166 Diff: diff,
167 },
168 },
169 )
170 if !p {
171 return ToolResponse{}, permission.ErrorPermissionDenied
172 }
173
174 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
175 if err != nil {
176 return ToolResponse{}, fmt.Errorf("error writing file: %w", err)
177 }
178
179 recordFileWrite(filePath)
180 recordFileRead(filePath)
181 waitForLspDiagnostics(ctx, filePath, w.lspClients)
182
183 result := fmt.Sprintf("File successfully written: %s", filePath)
184 result = fmt.Sprintf("<result>\n%s\n</result>", result)
185 result += getDiagnostics(filePath, w.lspClients)
186 return WithResponseMetadata(NewTextResponse(result),
187 WriteResponseMetadata{
188 Diff: diff,
189 Additions: additions,
190 Removals: removals,
191 },
192 ), nil
193}