view.go

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