1package tools
2
3import (
4 "bufio"
5 "context"
6 _ "embed"
7 "fmt"
8 "io"
9 "io/fs"
10 "net/http"
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 = 200 * 1024 // 200KB
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 FirstLineDescription(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 NewPermissionDeniedResponse(), nil
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 // Some tools save files with a mismatched extension
193 // (e.g. pinchtab writes JPEG bytes to a .png file).
194 // Providers like Anthropic strictly validate the
195 // media type against the base64 magic bytes and 400
196 // on mismatch, so prefer the sniffed type whenever
197 // it identifies a supported image format.
198 mimeType = sniffImageMimeType(imageData, mimeType)
199
200 return fantasy.NewImageResponse(imageData, mimeType), nil
201 }
202
203 // Read the file content
204 content, hasMore, err := readTextFile(filePath, params.Offset, params.Limit)
205 if err != nil {
206 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
207 }
208 if !utf8.ValidString(content) {
209 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
210 }
211
212 openInLSPs(ctx, lspManager, filePath)
213 waitForLSPDiagnostics(ctx, lspManager, filePath, 300*time.Millisecond)
214 output := "<file>\n"
215 output += addLineNumbers(content, params.Offset+1)
216
217 if hasMore {
218 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
219 params.Offset+len(strings.Split(content, "\n")))
220 }
221 output += "\n</file>\n"
222 output += getDiagnostics(filePath, lspManager)
223 filetracker.RecordRead(ctx, sessionID, filePath)
224
225 meta := ViewResponseMetadata{
226 FilePath: filePath,
227 Content: content,
228 }
229 if isSkillFile {
230 if skill, err := skills.Parse(filePath); err == nil {
231 meta.ResourceType = ViewResourceSkill
232 meta.ResourceName = skill.Name
233 meta.ResourceDescription = skill.Description
234 skillTracker.MarkLoaded(skill.Name)
235 }
236 }
237
238 return fantasy.WithResponseMetadata(
239 fantasy.NewTextResponse(output),
240 meta,
241 ), nil
242 })
243}
244
245func addLineNumbers(content string, startLine int) string {
246 if content == "" {
247 return ""
248 }
249
250 lines := strings.Split(content, "\n")
251
252 var result []string
253 for i, line := range lines {
254 line = strings.TrimSuffix(line, "\r")
255
256 lineNum := i + startLine
257 numStr := fmt.Sprintf("%d", lineNum)
258
259 if len(numStr) >= 6 {
260 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
261 } else {
262 paddedNum := fmt.Sprintf("%6s", numStr)
263 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
264 }
265 }
266
267 return strings.Join(result, "\n")
268}
269
270func readTextFile(filePath string, offset, limit int) (string, bool, error) {
271 file, err := os.Open(filePath)
272 if err != nil {
273 return "", false, err
274 }
275 defer file.Close()
276
277 scanner := NewLineScanner(file)
278 if offset > 0 {
279 skipped := 0
280 for skipped < offset && scanner.Scan() {
281 skipped++
282 }
283 if err = scanner.Err(); err != nil {
284 return "", false, err
285 }
286 }
287
288 // Pre-allocate slice with expected capacity.
289 lines := make([]string, 0, limit)
290
291 for len(lines) < limit && scanner.Scan() {
292 lineText := scanner.Text()
293 if len(lineText) > MaxLineLength {
294 lineText = lineText[:MaxLineLength] + "..."
295 }
296 lines = append(lines, lineText)
297 }
298
299 // Peek one more line only when we filled the limit.
300 hasMore := len(lines) == limit && scanner.Scan()
301
302 if err := scanner.Err(); err != nil {
303 return "", false, err
304 }
305
306 return strings.Join(lines, "\n"), hasMore, nil
307}
308
309func getImageMimeType(filePath string) (bool, string) {
310 ext := strings.ToLower(filepath.Ext(filePath))
311 switch ext {
312 case ".jpg", ".jpeg":
313 return true, "image/jpeg"
314 case ".png":
315 return true, "image/png"
316 case ".gif":
317 return true, "image/gif"
318 case ".webp":
319 return true, "image/webp"
320 default:
321 return false, ""
322 }
323}
324
325// sniffImageMimeType returns the content-sniffed MIME type when it identifies
326// a supported image format. Otherwise it returns the provided fallback, which
327// is usually the extension-derived type. Providers that validate the image
328// media type against the base64 magic bytes (e.g. Anthropic) reject mismatched
329// requests with a 400, so trusting the filename alone is unsafe.
330func sniffImageMimeType(data []byte, fallback string) string {
331 sniffed := http.DetectContentType(data)
332 // http.DetectContentType may return the MIME with a ";" parameter
333 // (e.g. "image/svg+xml; charset=utf-8") although current image sniffers
334 // return bare types; strip defensively.
335 if i := strings.IndexByte(sniffed, ';'); i >= 0 {
336 sniffed = strings.TrimSpace(sniffed[:i])
337 }
338 switch sniffed {
339 case "image/jpeg", "image/png", "image/gif", "image/webp":
340 return sniffed
341 }
342 return fallback
343}
344
345type LineScanner struct {
346 scanner *bufio.Scanner
347}
348
349func NewLineScanner(r io.Reader) *LineScanner {
350 scanner := bufio.NewScanner(r)
351 // Increase buffer size to handle large lines (e.g., minified JSON, HTML)
352 // Default is 64KB, set to 1MB
353 buf := make([]byte, 0, 64*1024)
354 scanner.Buffer(buf, 1024*1024)
355 return &LineScanner{
356 scanner: scanner,
357 }
358}
359
360func (s *LineScanner) Scan() bool {
361 return s.scanner.Scan()
362}
363
364func (s *LineScanner) Text() string {
365 return s.scanner.Text()
366}
367
368func (s *LineScanner) Err() error {
369 return s.scanner.Err()
370}
371
372// isInSkillsPath checks if filePath is within any of the configured skills
373// directories. Returns true for files that can be read without permission
374// prompts and without size limits.
375//
376// Note that symlinks are resolved to prevent path traversal attacks via
377// symbolic links.
378func isInSkillsPath(filePath string, skillsPaths []string) bool {
379 if len(skillsPaths) == 0 {
380 return false
381 }
382
383 absFilePath, err := filepath.Abs(filePath)
384 if err != nil {
385 return false
386 }
387
388 evalFilePath, err := filepath.EvalSymlinks(absFilePath)
389 if err != nil {
390 return false
391 }
392
393 for _, skillsPath := range skillsPaths {
394 absSkillsPath, err := filepath.Abs(skillsPath)
395 if err != nil {
396 continue
397 }
398
399 evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
400 if err != nil {
401 continue
402 }
403
404 relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
405 if err == nil && !strings.HasPrefix(relPath, "..") {
406 return true
407 }
408 }
409
410 return false
411}
412
413// readBuiltinFile reads a file from the embedded builtin skills filesystem.
414func readBuiltinFile(params ViewParams, skillTracker *skills.Tracker) (fantasy.ToolResponse, error) {
415 embeddedPath := "builtin/" + strings.TrimPrefix(params.FilePath, skills.BuiltinPrefix)
416 builtinFS := skills.BuiltinFS()
417
418 data, err := fs.ReadFile(builtinFS, embeddedPath)
419 if err != nil {
420 return fantasy.NewTextErrorResponse(fmt.Sprintf("Builtin file not found: %s", params.FilePath)), nil
421 }
422
423 content := string(data)
424 if !utf8.ValidString(content) {
425 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
426 }
427
428 limit := params.Limit
429 if limit <= 0 {
430 limit = 1000000 // Effectively no limit for skill files.
431 }
432
433 lines := strings.Split(content, "\n")
434 offset := min(params.Offset, len(lines))
435 lines = lines[offset:]
436
437 hasMore := len(lines) > limit
438 if hasMore {
439 lines = lines[:limit]
440 }
441
442 output := "<file>\n"
443 output += addLineNumbers(strings.Join(lines, "\n"), offset+1)
444 if hasMore {
445 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
446 offset+len(lines))
447 }
448 output += "\n</file>\n"
449
450 meta := ViewResponseMetadata{
451 FilePath: params.FilePath,
452 Content: strings.Join(lines, "\n"),
453 }
454 if skill, err := skills.ParseContent(data); err == nil {
455 meta.ResourceType = ViewResourceSkill
456 meta.ResourceName = skill.Name
457 meta.ResourceDescription = skill.Description
458 skillTracker.MarkLoaded(skill.Name)
459 }
460
461 return fantasy.WithResponseMetadata(
462 fantasy.NewTextResponse(output),
463 meta,
464 ), nil
465}