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