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