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