view.go

  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}