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