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