1package tools
2
3import (
4 "bufio"
5 "context"
6 _ "embed"
7 "errors"
8 "fmt"
9 "html/template"
10 "io"
11 "io/fs"
12 "net/http"
13 "os"
14 "path/filepath"
15 "strings"
16 "time"
17 "unicode/utf8"
18
19 "charm.land/fantasy"
20 "github.com/charmbracelet/crush/internal/filepathext"
21 "github.com/charmbracelet/crush/internal/filetracker"
22 "github.com/charmbracelet/crush/internal/lsp"
23 "github.com/charmbracelet/crush/internal/permission"
24 "github.com/charmbracelet/crush/internal/skills"
25)
26
27//go:embed view.md.tpl
28var viewDescriptionTmpl []byte
29
30var viewDescriptionTpl = template.Must(
31 template.New("viewDescription").
32 Parse(string(viewDescriptionTmpl)),
33)
34
35type viewDescriptionData struct {
36 DefaultReadLimit int
37 MaxViewSizeKB int
38}
39
40func viewDescription() string {
41 return renderTemplate(viewDescriptionTpl, viewDescriptionData{
42 DefaultReadLimit: DefaultReadLimit,
43 MaxViewSizeKB: MaxViewSize / 1024,
44 })
45}
46
47type ViewParams struct {
48 FilePath string `json:"file_path" description:"The path to the file to read"`
49 Offset int `json:"offset,omitempty" description:"The line number to start reading from (0-based)"`
50 Limit int `json:"limit,omitempty" description:"The number of lines to read (defaults to 2000)"`
51}
52
53type ViewPermissionsParams struct {
54 FilePath string `json:"file_path"`
55 Offset int `json:"offset"`
56 Limit int `json:"limit"`
57}
58
59type ViewResourceType string
60
61const (
62 ViewResourceUnset ViewResourceType = ""
63 ViewResourceSkill ViewResourceType = "skill"
64)
65
66type ViewResponseMetadata struct {
67 FilePath string `json:"file_path"`
68 Content string `json:"content"`
69 ResourceType ViewResourceType `json:"resource_type,omitempty"`
70 ResourceName string `json:"resource_name,omitempty"`
71 ResourceDescription string `json:"resource_description,omitempty"`
72}
73
74const (
75 ViewToolName = "view"
76 MaxViewSize = 200 * 1024 // 200KB
77 DefaultReadLimit = 2000
78 MaxLineLength = 2000
79)
80
81type contentTooLargeError struct {
82 Size int
83 Max int
84}
85
86func (e contentTooLargeError) Error() string {
87 return fmt.Sprintf("content section is too large (%d bytes). Maximum size is %d bytes", e.Size, e.Max)
88}
89
90func NewViewTool(
91 lspManager *lsp.Manager,
92 permissions permission.Service,
93 filetracker filetracker.Service,
94 skillTracker *skills.Tracker,
95 workingDir string,
96 skillsPaths ...string,
97) fantasy.AgentTool {
98 return fantasy.NewAgentTool(
99 ViewToolName,
100 viewDescription(),
101 func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
102 if params.FilePath == "" {
103 return fantasy.NewTextErrorResponse("file_path is required"), nil
104 }
105
106 // Handle builtin skill files (crush: prefix).
107 if strings.HasPrefix(params.FilePath, skills.BuiltinPrefix) {
108 resp, err := readBuiltinFile(params, skillTracker)
109 return resp, err
110 }
111
112 // Handle relative paths
113 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
114
115 // Check if file is outside working directory and request permission if needed
116 absWorkingDir, err := filepath.Abs(workingDir)
117 if err != nil {
118 return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
119 }
120
121 absFilePath, err := filepath.Abs(filePath)
122 if err != nil {
123 return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
124 }
125
126 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
127 isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
128 isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
129
130 sessionID := GetSessionFromContext(ctx)
131 if sessionID == "" {
132 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
133 }
134
135 // Request permission for files outside working directory, unless it's a skill file.
136 if isOutsideWorkDir && !isSkillFile {
137 granted, permReqErr := permissions.Request(ctx,
138 permission.CreatePermissionRequest{
139 SessionID: sessionID,
140 Path: absFilePath,
141 ToolCallID: call.ID,
142 ToolName: ViewToolName,
143 Action: "read",
144 Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
145 Params: ViewPermissionsParams(params),
146 },
147 )
148 if permReqErr != nil {
149 return fantasy.ToolResponse{}, permReqErr
150 }
151 if !granted {
152 return NewPermissionDeniedResponse(), nil
153 }
154 }
155
156 // Check if file exists
157 fileInfo, err := os.Stat(filePath)
158 if err != nil {
159 if os.IsNotExist(err) {
160 // Try to offer suggestions for similarly named files
161 dir := filepath.Dir(filePath)
162 base := filepath.Base(filePath)
163
164 dirEntries, dirErr := os.ReadDir(dir)
165 if dirErr == nil {
166 var suggestions []string
167 for _, entry := range dirEntries {
168 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
169 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
170 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
171 if len(suggestions) >= 3 {
172 break
173 }
174 }
175 }
176
177 if len(suggestions) > 0 {
178 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
179 filePath, strings.Join(suggestions, "\n"))), nil
180 }
181 }
182
183 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
184 }
185 return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
186 }
187
188 // Check if it's a directory
189 if fileInfo.IsDir() {
190 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
191 }
192
193 // Set default limit if not provided (no limit for SKILL.md files)
194 if params.Limit <= 0 {
195 if isSkillFile {
196 params.Limit = 1000000 // Effectively no limit for skill files
197 } else {
198 params.Limit = DefaultReadLimit
199 }
200 }
201
202 isSupportedImage, mimeType := getImageMimeType(filePath)
203 if isSupportedImage {
204 if fileInfo.Size() > MaxViewSize {
205 return fantasy.NewTextErrorResponse(fmt.Sprintf("Image file is too large (%d bytes). Maximum size is %d bytes",
206 fileInfo.Size(), MaxViewSize)), nil
207 }
208 if !GetSupportsImagesFromContext(ctx) {
209 modelName := GetModelNameFromContext(ctx)
210 return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
211 }
212
213 imageData, readErr := os.ReadFile(filePath)
214 if readErr != nil {
215 return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", readErr)
216 }
217
218 // Some tools save files with a mismatched extension
219 // (e.g. pinchtab writes JPEG bytes to a .png file).
220 // Providers like Anthropic strictly validate the
221 // media type against the base64 magic bytes and 400
222 // on mismatch, so prefer the sniffed type whenever
223 // it identifies a supported image format.
224 mimeType = sniffImageMimeType(imageData, mimeType)
225
226 return fantasy.NewImageResponse(imageData, mimeType), nil
227 }
228
229 // Read the file content
230 maxContentSize := MaxViewSize
231 if isSkillFile {
232 maxContentSize = 0
233 }
234 content, hasMore, err := readTextFile(filePath, params.Offset, params.Limit, maxContentSize)
235 if err != nil {
236 var tooLarge contentTooLargeError
237 if errors.As(err, &tooLarge) {
238 return fantasy.NewTextErrorResponse(fmt.Sprintf("Content section is too large (%d bytes). Maximum size is %d bytes",
239 tooLarge.Size, tooLarge.Max)), nil
240 }
241 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
242 }
243 if !utf8.ValidString(content) {
244 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
245 }
246
247 openInLSPs(ctx, lspManager, filePath)
248 waitForLSPDiagnostics(ctx, lspManager, filePath, 300*time.Millisecond)
249 output := "<file>\n"
250 output += addLineNumbers(content, params.Offset+1)
251
252 if hasMore {
253 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
254 params.Offset+len(strings.Split(content, "\n")))
255 }
256 output += "\n</file>\n"
257 output += getDiagnostics(filePath, lspManager)
258 filetracker.RecordRead(ctx, sessionID, filePath)
259
260 meta := ViewResponseMetadata{
261 FilePath: filePath,
262 Content: content,
263 }
264 if isSkillFile {
265 if skill, err := skills.Parse(filePath); err == nil {
266 meta.ResourceType = ViewResourceSkill
267 meta.ResourceName = skill.Name
268 meta.ResourceDescription = skill.Description
269 skillTracker.MarkLoaded(skill.Name)
270 }
271 }
272
273 return fantasy.WithResponseMetadata(
274 fantasy.NewTextResponse(output),
275 meta,
276 ), nil
277 })
278}
279
280func addLineNumbers(content string, startLine int) string {
281 if content == "" {
282 return ""
283 }
284
285 lines := strings.Split(content, "\n")
286
287 var result []string
288 for i, line := range lines {
289 line = strings.TrimSuffix(line, "\r")
290
291 lineNum := i + startLine
292 numStr := fmt.Sprintf("%d", lineNum)
293
294 if len(numStr) >= 6 {
295 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
296 } else {
297 paddedNum := fmt.Sprintf("%6s", numStr)
298 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
299 }
300 }
301
302 return strings.Join(result, "\n")
303}
304
305func readTextFile(filePath string, offset, limit, maxContentSize int) (string, bool, error) {
306 file, err := os.Open(filePath)
307 if err != nil {
308 return "", false, err
309 }
310 defer file.Close()
311
312 scanner := NewLineScanner(file)
313 if offset > 0 {
314 skipped := 0
315 for skipped < offset && scanner.Scan() {
316 skipped++
317 }
318 if err = scanner.Err(); err != nil {
319 return "", false, err
320 }
321 }
322
323 lines := make([]string, 0, min(limit, DefaultReadLimit))
324 contentSize := 0
325
326 for len(lines) < limit && scanner.Scan() {
327 lineText := scanner.Text()
328 if len(lineText) > MaxLineLength {
329 lineText = lineText[:MaxLineLength] + "..."
330 }
331 projectedSize := contentSize + len(lineText)
332 if len(lines) > 0 {
333 projectedSize++
334 }
335 if maxContentSize > 0 && projectedSize > maxContentSize {
336 return "", false, contentTooLargeError{Size: projectedSize, Max: maxContentSize}
337 }
338 contentSize = projectedSize
339 lines = append(lines, lineText)
340 }
341
342 // Peek one more line only when we filled the limit.
343 hasMore := len(lines) == limit && scanner.Scan()
344
345 if err := scanner.Err(); err != nil {
346 return "", false, err
347 }
348
349 return strings.Join(lines, "\n"), hasMore, nil
350}
351
352func getImageMimeType(filePath string) (bool, string) {
353 ext := strings.ToLower(filepath.Ext(filePath))
354 switch ext {
355 case ".jpg", ".jpeg":
356 return true, "image/jpeg"
357 case ".png":
358 return true, "image/png"
359 case ".gif":
360 return true, "image/gif"
361 case ".webp":
362 return true, "image/webp"
363 default:
364 return false, ""
365 }
366}
367
368// sniffImageMimeType returns the content-sniffed MIME type when it identifies
369// a supported image format. Otherwise it returns the provided fallback, which
370// is usually the extension-derived type. Providers that validate the image
371// media type against the base64 magic bytes (e.g. Anthropic) reject mismatched
372// requests with a 400, so trusting the filename alone is unsafe.
373func sniffImageMimeType(data []byte, fallback string) string {
374 sniffed := http.DetectContentType(data)
375 // http.DetectContentType may return the MIME with a ";" parameter
376 // (e.g. "image/svg+xml; charset=utf-8") although current image sniffers
377 // return bare types; strip defensively.
378 if i := strings.IndexByte(sniffed, ';'); i >= 0 {
379 sniffed = strings.TrimSpace(sniffed[:i])
380 }
381 switch sniffed {
382 case "image/jpeg", "image/png", "image/gif", "image/webp":
383 return sniffed
384 }
385 return fallback
386}
387
388type LineScanner struct {
389 scanner *bufio.Scanner
390}
391
392func NewLineScanner(r io.Reader) *LineScanner {
393 scanner := bufio.NewScanner(r)
394 // Increase buffer size to handle large lines (e.g., minified JSON, HTML)
395 // Default is 64KB, set to 1MB
396 buf := make([]byte, 0, 64*1024)
397 scanner.Buffer(buf, 1024*1024)
398 return &LineScanner{
399 scanner: scanner,
400 }
401}
402
403func (s *LineScanner) Scan() bool {
404 return s.scanner.Scan()
405}
406
407func (s *LineScanner) Text() string {
408 return s.scanner.Text()
409}
410
411func (s *LineScanner) Err() error {
412 return s.scanner.Err()
413}
414
415// isInSkillsPath checks if filePath is within any of the configured skills
416// directories. Returns true for files that can be read without permission
417// prompts and without size limits.
418//
419// Note that symlinks are resolved to prevent path traversal attacks via
420// symbolic links.
421func isInSkillsPath(filePath string, skillsPaths []string) bool {
422 if len(skillsPaths) == 0 {
423 return false
424 }
425
426 absFilePath, err := filepath.Abs(filePath)
427 if err != nil {
428 return false
429 }
430
431 evalFilePath, err := filepath.EvalSymlinks(absFilePath)
432 if err != nil {
433 return false
434 }
435
436 for _, skillsPath := range skillsPaths {
437 absSkillsPath, err := filepath.Abs(skillsPath)
438 if err != nil {
439 continue
440 }
441
442 evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
443 if err != nil {
444 continue
445 }
446
447 relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
448 if err == nil && !strings.HasPrefix(relPath, "..") {
449 return true
450 }
451 }
452
453 return false
454}
455
456// readBuiltinFile reads a file from the embedded builtin skills filesystem.
457func readBuiltinFile(params ViewParams, skillTracker *skills.Tracker) (fantasy.ToolResponse, error) {
458 embeddedPath := "builtin/" + strings.TrimPrefix(params.FilePath, skills.BuiltinPrefix)
459 builtinFS := skills.BuiltinFS()
460
461 data, err := fs.ReadFile(builtinFS, embeddedPath)
462 if err != nil {
463 return fantasy.NewTextErrorResponse(fmt.Sprintf("Builtin file not found: %s", params.FilePath)), nil
464 }
465
466 content := string(data)
467 if !utf8.ValidString(content) {
468 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
469 }
470
471 limit := params.Limit
472 if limit <= 0 {
473 limit = 1000000 // Effectively no limit for skill files.
474 }
475
476 lines := strings.Split(content, "\n")
477 offset := min(params.Offset, len(lines))
478 lines = lines[offset:]
479
480 hasMore := len(lines) > limit
481 if hasMore {
482 lines = lines[:limit]
483 }
484
485 output := "<file>\n"
486 output += addLineNumbers(strings.Join(lines, "\n"), offset+1)
487 if hasMore {
488 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
489 offset+len(lines))
490 }
491 output += "\n</file>\n"
492
493 meta := ViewResponseMetadata{
494 FilePath: params.FilePath,
495 Content: strings.Join(lines, "\n"),
496 }
497 if skill, err := skills.ParseContent(data); err == nil {
498 meta.ResourceType = ViewResourceSkill
499 meta.ResourceName = skill.Name
500 meta.ResourceDescription = skill.Description
501 skillTracker.MarkLoaded(skill.Name)
502 }
503
504 return fantasy.WithResponseMetadata(
505 fantasy.NewTextResponse(output),
506 meta,
507 ), nil
508}