1package tools
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "charm.land/fantasy"
14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
17 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/history"
19
20 "github.com/charmbracelet/crush/internal/lsp"
21 "github.com/charmbracelet/crush/internal/permission"
22)
23
24//go:embed write.md
25var writeDescription []byte
26
27type WriteParams struct {
28 FilePath string `json:"file_path" description:"The path to the file to write"`
29 Content string `json:"content" description:"The content to write to the file"`
30}
31
32type WritePermissionsParams struct {
33 FilePath string `json:"file_path"`
34 OldContent string `json:"old_content,omitempty"`
35 NewContent string `json:"new_content,omitempty"`
36}
37
38type writeTool struct {
39 lspClients *csync.Map[string, *lsp.Client]
40 permissions permission.Service
41 files history.Service
42 workingDir string
43}
44
45type WriteResponseMetadata struct {
46 FilePath string `json:"file_path"`
47 OldContent string `json:"old_content,omitempty"`
48 NewContent string `json:"new_content,omitempty"`
49 Diff string `json:"diff"`
50 Additions int `json:"additions"`
51 Removals int `json:"removals"`
52}
53
54const WriteToolName = "write"
55
56func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
57 return fantasy.NewAgentTool(
58 WriteToolName,
59 string(writeDescription),
60 func(ctx context.Context, params WriteParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
61 if params.FilePath == "" {
62 return fantasy.NewTextErrorResponse("file_path is required"), nil
63 }
64
65 if params.Content == "" {
66 return fantasy.NewTextErrorResponse("content is required"), nil
67 }
68
69 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
70
71 fileInfo, err := os.Stat(filePath)
72 if err == nil {
73 if fileInfo.IsDir() {
74 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
75 }
76
77 modTime := fileInfo.ModTime()
78 lastRead := getLastReadTime(filePath)
79 if modTime.After(lastRead) {
80 return fantasy.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.",
81 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))), nil
82 }
83
84 oldContent, readErr := os.ReadFile(filePath)
85 if readErr == nil && string(oldContent) == params.Content {
86 return fantasy.NewTextErrorResponse(fmt.Sprintf("File %s already contains the exact content. No changes made.", filePath)), nil
87 }
88 } else if !os.IsNotExist(err) {
89 return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
90 }
91
92 dir := filepath.Dir(filePath)
93 if err = os.MkdirAll(dir, 0o755); err != nil {
94 return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
95 }
96
97 oldContent := ""
98 if fileInfo != nil && !fileInfo.IsDir() {
99 oldBytes, readErr := os.ReadFile(filePath)
100 if readErr == nil {
101 oldContent = string(oldBytes)
102 }
103 }
104
105 sessionID := GetSessionFromContext(ctx)
106 if sessionID == "" {
107 return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
108 }
109
110 diff, additions, removals := diff.GenerateDiff(
111 oldContent,
112 params.Content,
113 strings.TrimPrefix(filePath, workingDir),
114 )
115
116 p := permissions.Request(
117 permission.CreatePermissionRequest{
118 SessionID: sessionID,
119 Path: fsext.PathOrPrefix(filePath, workingDir),
120 ToolCallID: call.ID,
121 ToolName: WriteToolName,
122 Action: "write",
123 Description: fmt.Sprintf("Create file %s", filePath),
124 Params: WritePermissionsParams{
125 FilePath: filePath,
126 OldContent: oldContent,
127 NewContent: params.Content,
128 },
129 },
130 )
131 if !p {
132 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
133 }
134
135 err = os.WriteFile(filePath, []byte(params.Content), 0o644)
136 if err != nil {
137 return fantasy.ToolResponse{}, fmt.Errorf("error writing file: %w", err)
138 }
139
140 // Check if file exists in history
141 file, err := files.GetByPathAndSession(ctx, filePath, sessionID)
142 if err != nil {
143 _, err = files.Create(ctx, sessionID, filePath, oldContent)
144 if err != nil {
145 // Log error but don't fail the operation
146 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
147 }
148 }
149 if file.Content != oldContent {
150 // User Manually changed the content store an intermediate version
151 _, err = files.CreateVersion(ctx, sessionID, filePath, oldContent)
152 if err != nil {
153 slog.Error("Error creating file history version", "error", err)
154 }
155 }
156 // Store the new version
157 _, err = files.CreateVersion(ctx, sessionID, filePath, params.Content)
158 if err != nil {
159 slog.Error("Error creating file history version", "error", err)
160 }
161
162 recordFileWrite(filePath)
163 recordFileRead(filePath)
164
165 notifyLSPs(ctx, lspClients, params.FilePath)
166
167 result := fmt.Sprintf("File successfully written: %s", filePath)
168 result = fmt.Sprintf("<result>\n%s\n</result>", result)
169 result += getDiagnostics(filePath, lspClients)
170 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
171 WriteResponseMetadata{
172 FilePath: filePath,
173 OldContent: oldContent,
174 NewContent: params.Content,
175 Diff: diff,
176 Additions: additions,
177 Removals: removals,
178 },
179 ), nil
180 })
181}