view.go

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