1package tools
2
3import (
4 "bufio"
5 "context"
6 _ "embed"
7 "errors"
8 "fmt"
9 "io"
10 "io/fs"
11 "net/http"
12 "os"
13 "path/filepath"
14 "strings"
15 "time"
16 "unicode/utf8"
17
18 "charm.land/fantasy"
19 "github.com/charmbracelet/crush/internal/filepathext"
20 "github.com/charmbracelet/crush/internal/filetracker"
21 "github.com/charmbracelet/crush/internal/lsp"
22 "github.com/charmbracelet/crush/internal/permission"
23 "github.com/charmbracelet/crush/internal/skills"
24)
25
26//go:embed view.md
27var viewDescription []byte
28
29type ViewParams struct {
30 FilePath string `json:"file_path" description:"The path to the file to read"`
31 Offset int `json:"offset,omitempty" description:"The line number to start reading from (0-based)"`
32 Limit int `json:"limit,omitempty" description:"The number of lines to read (defaults to 2000)"`
33}
34
35type ViewPermissionsParams struct {
36 FilePath string `json:"file_path"`
37 Offset int `json:"offset"`
38 Limit int `json:"limit"`
39}
40
41type ViewResourceType string
42
43const (
44 ViewResourceUnset ViewResourceType = ""
45 ViewResourceSkill ViewResourceType = "skill"
46)
47
48type ViewResponseMetadata struct {
49 FilePath string `json:"file_path"`
50 Content string `json:"content"`
51 ResourceType ViewResourceType `json:"resource_type,omitempty"`
52 ResourceName string `json:"resource_name,omitempty"`
53 ResourceDescription string `json:"resource_description,omitempty"`
54}
55
56const (
57 ViewToolName = "view"
58 MaxViewSize = 200 * 1024 // 200KB
59 DefaultReadLimit = 2000
60 MaxLineLength = 2000
61)
62
63type contentTooLargeError struct {
64 Size int
65 Max int
66}
67
68func (e contentTooLargeError) Error() string {
69 return fmt.Sprintf("content section is too large (%d bytes). Maximum size is %d bytes", e.Size, e.Max)
70}
71
72func NewViewTool(
73 lspManager *lsp.Manager,
74 permissions permission.Service,
75 filetracker filetracker.Service,
76 skillTracker *skills.Tracker,
77 workingDir string,
78 skillsPaths ...string,
79) fantasy.AgentTool {
80 return fantasy.NewAgentTool(
81 ViewToolName,
82 FirstLineDescription(viewDescription),
83 func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
84 if params.FilePath == "" {
85 return fantasy.NewTextErrorResponse("file_path is required"), nil
86 }
87
88 // Handle builtin skill files (crush: prefix).
89 if strings.HasPrefix(params.FilePath, skills.BuiltinPrefix) {
90 resp, err := readBuiltinFile(params, skillTracker)
91 return resp, err
92 }
93
94 // Handle relative paths
95 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
96
97 // Check if file is outside working directory and request permission if needed
98 absWorkingDir, err := filepath.Abs(workingDir)
99 if err != nil {
100 return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
101 }
102
103 absFilePath, err := filepath.Abs(filePath)
104 if err != nil {
105 return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
106 }
107
108 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
109 isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
110 isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
111
112 sessionID := GetSessionFromContext(ctx)
113 if sessionID == "" {
114 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
115 }
116
117 // Request permission for files outside working directory, unless it's a skill file.
118 if isOutsideWorkDir && !isSkillFile {
119 granted, permReqErr := permissions.Request(ctx,
120 permission.CreatePermissionRequest{
121 SessionID: sessionID,
122 Path: absFilePath,
123 ToolCallID: call.ID,
124 ToolName: ViewToolName,
125 Action: "read",
126 Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
127 Params: ViewPermissionsParams(params),
128 },
129 )
130 if permReqErr != nil {
131 return fantasy.ToolResponse{}, permReqErr
132 }
133 if !granted {
134 return NewPermissionDeniedResponse(), nil
135 }
136 }
137
138 // Check if file exists
139 fileInfo, err := os.Stat(filePath)
140 if err != nil {
141 if os.IsNotExist(err) {
142 // Try to offer suggestions for similarly named files
143 dir := filepath.Dir(filePath)
144 base := filepath.Base(filePath)
145
146 dirEntries, dirErr := os.ReadDir(dir)
147 if dirErr == nil {
148 var suggestions []string
149 for _, entry := range dirEntries {
150 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
151 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
152 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
153 if len(suggestions) >= 3 {
154 break
155 }
156 }
157 }
158
159 if len(suggestions) > 0 {
160 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
161 filePath, strings.Join(suggestions, "\n"))), nil
162 }
163 }
164
165 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
166 }
167 return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
168 }
169
170 // Check if it's a directory
171 if fileInfo.IsDir() {
172 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
173 }
174
175 // Set default limit if not provided (no limit for SKILL.md files)
176 if params.Limit <= 0 {
177 if isSkillFile {
178 params.Limit = 1000000 // Effectively no limit for skill files
179 } else {
180 params.Limit = DefaultReadLimit
181 }
182 }
183
184 isSupportedImage, mimeType := getImageMimeType(filePath)
185 if isSupportedImage {
186 if fileInfo.Size() > MaxViewSize {
187 return fantasy.NewTextErrorResponse(fmt.Sprintf("Image file is too large (%d bytes). Maximum size is %d bytes",
188 fileInfo.Size(), MaxViewSize)), nil
189 }
190 if !GetSupportsImagesFromContext(ctx) {
191 modelName := GetModelNameFromContext(ctx)
192 return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
193 }
194
195 imageData, readErr := os.ReadFile(filePath)
196 if readErr != nil {
197 return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", readErr)
198 }
199
200 // Some tools save files with a mismatched extension
201 // (e.g. pinchtab writes JPEG bytes to a .png file).
202 // Providers like Anthropic strictly validate the
203 // media type against the base64 magic bytes and 400
204 // on mismatch, so prefer the sniffed type whenever
205 // it identifies a supported image format.
206 mimeType = sniffImageMimeType(imageData, mimeType)
207
208 return fantasy.NewImageResponse(imageData, mimeType), nil
209 }
210
211 // Read the file content
212 maxContentSize := MaxViewSize
213 if isSkillFile {
214 maxContentSize = 0
215 }
216 content, hasMore, err := readTextFile(filePath, params.Offset, params.Limit, maxContentSize)
217 if err != nil {
218 var tooLarge contentTooLargeError
219 if errors.As(err, &tooLarge) {
220 return fantasy.NewTextErrorResponse(fmt.Sprintf("Content section is too large (%d bytes). Maximum size is %d bytes",
221 tooLarge.Size, tooLarge.Max)), nil
222 }
223 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
224 }
225 if !utf8.ValidString(content) {
226 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
227 }
228
229 openInLSPs(ctx, lspManager, filePath)
230 waitForLSPDiagnostics(ctx, lspManager, filePath, 300*time.Millisecond)
231 output := "<file>\n"
232 output += addLineNumbers(content, params.Offset+1)
233
234 if hasMore {
235 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
236 params.Offset+len(strings.Split(content, "\n")))
237 }
238 output += "\n</file>\n"
239 output += getDiagnostics(filePath, lspManager)
240 filetracker.RecordRead(ctx, sessionID, filePath)
241
242 meta := ViewResponseMetadata{
243 FilePath: filePath,
244 Content: content,
245 }
246 if isSkillFile {
247 if skill, err := skills.Parse(filePath); err == nil {
248 meta.ResourceType = ViewResourceSkill
249 meta.ResourceName = skill.Name
250 meta.ResourceDescription = skill.Description
251 skillTracker.MarkLoaded(skill.Name)
252 }
253 }
254
255 return fantasy.WithResponseMetadata(
256 fantasy.NewTextResponse(output),
257 meta,
258 ), nil
259 })
260}
261
262func addLineNumbers(content string, startLine int) string {
263 if content == "" {
264 return ""
265 }
266
267 lines := strings.Split(content, "\n")
268
269 var result []string
270 for i, line := range lines {
271 line = strings.TrimSuffix(line, "\r")
272
273 lineNum := i + startLine
274 numStr := fmt.Sprintf("%d", lineNum)
275
276 if len(numStr) >= 6 {
277 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
278 } else {
279 paddedNum := fmt.Sprintf("%6s", numStr)
280 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
281 }
282 }
283
284 return strings.Join(result, "\n")
285}
286
287func readTextFile(filePath string, offset, limit, maxContentSize int) (string, bool, error) {
288 file, err := os.Open(filePath)
289 if err != nil {
290 return "", false, err
291 }
292 defer file.Close()
293
294 scanner := NewLineScanner(file)
295 if offset > 0 {
296 skipped := 0
297 for skipped < offset && scanner.Scan() {
298 skipped++
299 }
300 if err = scanner.Err(); err != nil {
301 return "", false, err
302 }
303 }
304
305 lines := make([]string, 0, min(limit, DefaultReadLimit))
306 contentSize := 0
307
308 for len(lines) < limit && scanner.Scan() {
309 lineText := scanner.Text()
310 if len(lineText) > MaxLineLength {
311 lineText = lineText[:MaxLineLength] + "..."
312 }
313 projectedSize := contentSize + len(lineText)
314 if len(lines) > 0 {
315 projectedSize++
316 }
317 if maxContentSize > 0 && projectedSize > maxContentSize {
318 return "", false, contentTooLargeError{Size: projectedSize, Max: maxContentSize}
319 }
320 contentSize = projectedSize
321 lines = append(lines, lineText)
322 }
323
324 // Peek one more line only when we filled the limit.
325 hasMore := len(lines) == limit && scanner.Scan()
326
327 if err := scanner.Err(); err != nil {
328 return "", false, err
329 }
330
331 return strings.Join(lines, "\n"), hasMore, nil
332}
333
334func getImageMimeType(filePath string) (bool, string) {
335 ext := strings.ToLower(filepath.Ext(filePath))
336 switch ext {
337 case ".jpg", ".jpeg":
338 return true, "image/jpeg"
339 case ".png":
340 return true, "image/png"
341 case ".gif":
342 return true, "image/gif"
343 case ".webp":
344 return true, "image/webp"
345 default:
346 return false, ""
347 }
348}
349
350// sniffImageMimeType returns the content-sniffed MIME type when it identifies
351// a supported image format. Otherwise it returns the provided fallback, which
352// is usually the extension-derived type. Providers that validate the image
353// media type against the base64 magic bytes (e.g. Anthropic) reject mismatched
354// requests with a 400, so trusting the filename alone is unsafe.
355func sniffImageMimeType(data []byte, fallback string) string {
356 sniffed := http.DetectContentType(data)
357 // http.DetectContentType may return the MIME with a ";" parameter
358 // (e.g. "image/svg+xml; charset=utf-8") although current image sniffers
359 // return bare types; strip defensively.
360 if i := strings.IndexByte(sniffed, ';'); i >= 0 {
361 sniffed = strings.TrimSpace(sniffed[:i])
362 }
363 switch sniffed {
364 case "image/jpeg", "image/png", "image/gif", "image/webp":
365 return sniffed
366 }
367 return fallback
368}
369
370type LineScanner struct {
371 scanner *bufio.Scanner
372}
373
374func NewLineScanner(r io.Reader) *LineScanner {
375 scanner := bufio.NewScanner(r)
376 // Increase buffer size to handle large lines (e.g., minified JSON, HTML)
377 // Default is 64KB, set to 1MB
378 buf := make([]byte, 0, 64*1024)
379 scanner.Buffer(buf, 1024*1024)
380 return &LineScanner{
381 scanner: scanner,
382 }
383}
384
385func (s *LineScanner) Scan() bool {
386 return s.scanner.Scan()
387}
388
389func (s *LineScanner) Text() string {
390 return s.scanner.Text()
391}
392
393func (s *LineScanner) Err() error {
394 return s.scanner.Err()
395}
396
397// isInSkillsPath checks if filePath is within any of the configured skills
398// directories. Returns true for files that can be read without permission
399// prompts and without size limits.
400//
401// Note that symlinks are resolved to prevent path traversal attacks via
402// symbolic links.
403func isInSkillsPath(filePath string, skillsPaths []string) bool {
404 if len(skillsPaths) == 0 {
405 return false
406 }
407
408 absFilePath, err := filepath.Abs(filePath)
409 if err != nil {
410 return false
411 }
412
413 evalFilePath, err := filepath.EvalSymlinks(absFilePath)
414 if err != nil {
415 return false
416 }
417
418 for _, skillsPath := range skillsPaths {
419 absSkillsPath, err := filepath.Abs(skillsPath)
420 if err != nil {
421 continue
422 }
423
424 evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
425 if err != nil {
426 continue
427 }
428
429 relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
430 if err == nil && !strings.HasPrefix(relPath, "..") {
431 return true
432 }
433 }
434
435 return false
436}
437
438// readBuiltinFile reads a file from the embedded builtin skills filesystem.
439func readBuiltinFile(params ViewParams, skillTracker *skills.Tracker) (fantasy.ToolResponse, error) {
440 embeddedPath := "builtin/" + strings.TrimPrefix(params.FilePath, skills.BuiltinPrefix)
441 builtinFS := skills.BuiltinFS()
442
443 data, err := fs.ReadFile(builtinFS, embeddedPath)
444 if err != nil {
445 return fantasy.NewTextErrorResponse(fmt.Sprintf("Builtin file not found: %s", params.FilePath)), nil
446 }
447
448 content := string(data)
449 if !utf8.ValidString(content) {
450 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
451 }
452
453 limit := params.Limit
454 if limit <= 0 {
455 limit = 1000000 // Effectively no limit for skill files.
456 }
457
458 lines := strings.Split(content, "\n")
459 offset := min(params.Offset, len(lines))
460 lines = lines[offset:]
461
462 hasMore := len(lines) > limit
463 if hasMore {
464 lines = lines[:limit]
465 }
466
467 output := "<file>\n"
468 output += addLineNumbers(strings.Join(lines, "\n"), offset+1)
469 if hasMore {
470 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
471 offset+len(lines))
472 }
473 output += "\n</file>\n"
474
475 meta := ViewResponseMetadata{
476 FilePath: params.FilePath,
477 Content: strings.Join(lines, "\n"),
478 }
479 if skill, err := skills.ParseContent(data); err == nil {
480 meta.ResourceType = ViewResourceSkill
481 meta.ResourceName = skill.Name
482 meta.ResourceDescription = skill.Description
483 skillTracker.MarkLoaded(skill.Name)
484 }
485
486 return fantasy.WithResponseMetadata(
487 fantasy.NewTextResponse(output),
488 meta,
489 ), nil
490}