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