view.go

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