1package tools
2
3import (
4 "bufio"
5 "context"
6 _ "embed"
7 "encoding/base64"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "strings"
13 "unicode/utf8"
14
15 "charm.land/fantasy"
16 "github.com/charmbracelet/crush/internal/csync"
17 "github.com/charmbracelet/crush/internal/filepathext"
18 "github.com/charmbracelet/crush/internal/filetracker"
19 "github.com/charmbracelet/crush/internal/lsp"
20 "github.com/charmbracelet/crush/internal/permission"
21)
22
23//go:embed view.md
24var viewDescription []byte
25
26type ViewParams struct {
27 FilePath string `json:"file_path" description:"The path to the file to read"`
28 Offset int `json:"offset,omitempty" description:"The line number to start reading from (0-based)"`
29 Limit int `json:"limit,omitempty" description:"The number of lines to read (defaults to 2000)"`
30}
31
32type ViewPermissionsParams struct {
33 FilePath string `json:"file_path"`
34 Offset int `json:"offset"`
35 Limit int `json:"limit"`
36}
37
38type viewTool struct {
39 lspClients *csync.Map[string, *lsp.Client]
40 workingDir string
41 permissions permission.Service
42 skillsPaths []string
43}
44
45type ViewResponseMetadata struct {
46 FilePath string `json:"file_path"`
47 Content string `json:"content"`
48}
49
50const (
51 ViewToolName = "view"
52 MaxReadSize = 5 * 1024 * 1024 // 5MB
53 DefaultReadLimit = 2000
54 MaxLineLength = 2000
55)
56
57func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string, skillsPaths ...string) fantasy.AgentTool {
58 return fantasy.NewAgentTool(
59 ViewToolName,
60 string(viewDescription),
61 func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
62 if params.FilePath == "" {
63 return fantasy.NewTextErrorResponse("file_path is required"), nil
64 }
65
66 // Handle relative paths
67 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
68
69 // Check if file is outside working directory and request permission if needed
70 absWorkingDir, err := filepath.Abs(workingDir)
71 if err != nil {
72 return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
73 }
74
75 absFilePath, err := filepath.Abs(filePath)
76 if err != nil {
77 return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
78 }
79
80 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
81 isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
82 isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
83
84 // Request permission for files outside working directory, unless it's a skill file.
85 if isOutsideWorkDir && !isSkillFile {
86 sessionID := GetSessionFromContext(ctx)
87 if sessionID == "" {
88 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
89 }
90
91 granted, err := permissions.Request(ctx,
92 permission.CreatePermissionRequest{
93 SessionID: sessionID,
94 Path: absFilePath,
95 ToolCallID: call.ID,
96 ToolName: ViewToolName,
97 Action: "read",
98 Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
99 Params: ViewPermissionsParams(params),
100 },
101 )
102 if err != nil {
103 return fantasy.ToolResponse{}, err
104 }
105 if !granted {
106 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
107 }
108 }
109
110 // Check if file exists
111 fileInfo, err := os.Stat(filePath)
112 if err != nil {
113 if os.IsNotExist(err) {
114 // Try to offer suggestions for similarly named files
115 dir := filepath.Dir(filePath)
116 base := filepath.Base(filePath)
117
118 dirEntries, dirErr := os.ReadDir(dir)
119 if dirErr == nil {
120 var suggestions []string
121 for _, entry := range dirEntries {
122 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
123 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
124 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
125 if len(suggestions) >= 3 {
126 break
127 }
128 }
129 }
130
131 if len(suggestions) > 0 {
132 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
133 filePath, strings.Join(suggestions, "\n"))), nil
134 }
135 }
136
137 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
138 }
139 return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
140 }
141
142 // Check if it's a directory
143 if fileInfo.IsDir() {
144 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
145 }
146
147 // Based on the specifications we should not limit the skills read.
148 if !isSkillFile && fileInfo.Size() > MaxReadSize {
149 return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
150 fileInfo.Size(), MaxReadSize)), nil
151 }
152
153 // Set default limit if not provided (no limit for SKILL.md files)
154 if params.Limit <= 0 {
155 if isSkillFile {
156 params.Limit = 1000000 // Effectively no limit for skill files
157 } else {
158 params.Limit = DefaultReadLimit
159 }
160 }
161
162 isSupportedImage, mimeType := getImageMimeType(filePath)
163 if isSupportedImage {
164 if !GetSupportsImagesFromContext(ctx) {
165 modelName := GetModelNameFromContext(ctx)
166 return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
167 }
168
169 imageData, err := os.ReadFile(filePath)
170 if err != nil {
171 return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", err)
172 }
173
174 encoded := base64.StdEncoding.EncodeToString(imageData)
175 return fantasy.NewImageResponse([]byte(encoded), mimeType), nil
176 }
177
178 // Read the file content
179 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
180 isValidUt8 := utf8.ValidString(content)
181 if !isValidUt8 {
182 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
183 }
184 if err != nil {
185 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
186 }
187
188 notifyLSPs(ctx, lspClients, filePath)
189 output := "<file>\n"
190 // Format the output with line numbers
191 output += addLineNumbers(content, params.Offset+1)
192
193 // Add a note if the content was truncated
194 if lineCount > params.Offset+len(strings.Split(content, "\n")) {
195 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
196 params.Offset+len(strings.Split(content, "\n")))
197 }
198 output += "\n</file>\n"
199 output += getDiagnostics(filePath, lspClients)
200 filetracker.RecordRead(filePath)
201 return fantasy.WithResponseMetadata(
202 fantasy.NewTextResponse(output),
203 ViewResponseMetadata{
204 FilePath: filePath,
205 Content: content,
206 },
207 ), nil
208 })
209}
210
211func addLineNumbers(content string, startLine int) string {
212 if content == "" {
213 return ""
214 }
215
216 lines := strings.Split(content, "\n")
217
218 var result []string
219 for i, line := range lines {
220 line = strings.TrimSuffix(line, "\r")
221
222 lineNum := i + startLine
223 numStr := fmt.Sprintf("%d", lineNum)
224
225 if len(numStr) >= 6 {
226 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
227 } else {
228 paddedNum := fmt.Sprintf("%6s", numStr)
229 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
230 }
231 }
232
233 return strings.Join(result, "\n")
234}
235
236func readTextFile(filePath string, offset, limit int) (string, int, error) {
237 file, err := os.Open(filePath)
238 if err != nil {
239 return "", 0, err
240 }
241 defer file.Close()
242
243 lineCount := 0
244
245 scanner := NewLineScanner(file)
246 if offset > 0 {
247 for lineCount < offset && scanner.Scan() {
248 lineCount++
249 }
250 if err = scanner.Err(); err != nil {
251 return "", 0, err
252 }
253 }
254
255 if offset == 0 {
256 _, err = file.Seek(0, io.SeekStart)
257 if err != nil {
258 return "", 0, err
259 }
260 }
261
262 // Pre-allocate slice with expected capacity
263 lines := make([]string, 0, limit)
264 lineCount = offset
265
266 for scanner.Scan() && len(lines) < limit {
267 lineCount++
268 lineText := scanner.Text()
269 if len(lineText) > MaxLineLength {
270 lineText = lineText[:MaxLineLength] + "..."
271 }
272 lines = append(lines, lineText)
273 }
274
275 // Continue scanning to get total line count
276 for scanner.Scan() {
277 lineCount++
278 }
279
280 if err := scanner.Err(); err != nil {
281 return "", 0, err
282 }
283
284 return strings.Join(lines, "\n"), lineCount, nil
285}
286
287func getImageMimeType(filePath string) (bool, string) {
288 ext := strings.ToLower(filepath.Ext(filePath))
289 switch ext {
290 case ".jpg", ".jpeg":
291 return true, "image/jpeg"
292 case ".png":
293 return true, "image/png"
294 case ".gif":
295 return true, "image/gif"
296 case ".webp":
297 return true, "image/webp"
298 default:
299 return false, ""
300 }
301}
302
303type LineScanner struct {
304 scanner *bufio.Scanner
305}
306
307func NewLineScanner(r io.Reader) *LineScanner {
308 scanner := bufio.NewScanner(r)
309 // Increase buffer size to handle large lines (e.g., minified JSON, HTML)
310 // Default is 64KB, set to 1MB
311 buf := make([]byte, 0, 64*1024)
312 scanner.Buffer(buf, 1024*1024)
313 return &LineScanner{
314 scanner: scanner,
315 }
316}
317
318func (s *LineScanner) Scan() bool {
319 return s.scanner.Scan()
320}
321
322func (s *LineScanner) Text() string {
323 return s.scanner.Text()
324}
325
326func (s *LineScanner) Err() error {
327 return s.scanner.Err()
328}
329
330// isInSkillsPath checks if filePath is within any of the configured skills
331// directories. Returns true for files that can be read without permission
332// prompts and without size limits.
333//
334// Note that symlinks are resolved to prevent path traversal attacks via
335// symbolic links.
336func isInSkillsPath(filePath string, skillsPaths []string) bool {
337 if len(skillsPaths) == 0 {
338 return false
339 }
340
341 absFilePath, err := filepath.Abs(filePath)
342 if err != nil {
343 return false
344 }
345
346 evalFilePath, err := filepath.EvalSymlinks(absFilePath)
347 if err != nil {
348 return false
349 }
350
351 for _, skillsPath := range skillsPaths {
352 absSkillsPath, err := filepath.Abs(skillsPath)
353 if err != nil {
354 continue
355 }
356
357 evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
358 if err != nil {
359 continue
360 }
361
362 relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
363 if err == nil && !strings.HasPrefix(relPath, "..") {
364 return true
365 }
366 }
367
368 return false
369}