view.go

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