view.go

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