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