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