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 reader := bufio.NewReader(file)
313 skipped := 0
314 for skipped < offset {
315 _, err := reader.ReadString('\n')
316 if err != nil {
317 if err == io.EOF {
318 return "", false, nil
319 }
320 return "", false, err
321 }
322 skipped++
323 }
324
325 lines := make([]string, 0, min(limit, DefaultReadLimit))
326 contentSize := 0
327
328 for len(lines) < limit {
329 lineText, err := reader.ReadString('\n')
330 if err != nil && err != io.EOF {
331 return "", false, err
332 }
333 lineText = strings.TrimSuffix(lineText, "\n")
334 lineText = strings.TrimSuffix(lineText, "\r")
335 if len(lineText) > MaxLineLength {
336 lineText = lineText[:MaxLineLength] + "..."
337 }
338 projectedSize := contentSize + len(lineText)
339 if len(lines) > 0 {
340 projectedSize++
341 }
342 if maxContentSize > 0 && projectedSize > maxContentSize {
343 return "", false, contentTooLargeError{Size: projectedSize, Max: maxContentSize}
344 }
345 contentSize = projectedSize
346 lines = append(lines, lineText)
347 if err == io.EOF {
348 break
349 }
350 }
351
352 // Peek one more line only when we filled the limit.
353 hasMore := false
354 if len(lines) == limit {
355 lineText, peekErr := reader.ReadString('\n')
356 hasMore = len(lineText) > 0 || peekErr == nil
357 }
358
359 return strings.Join(lines, "\n"), hasMore, nil
360}
361
362func getImageMimeType(filePath string) (bool, string) {
363 ext := strings.ToLower(filepath.Ext(filePath))
364 switch ext {
365 case ".jpg", ".jpeg":
366 return true, "image/jpeg"
367 case ".png":
368 return true, "image/png"
369 case ".gif":
370 return true, "image/gif"
371 case ".webp":
372 return true, "image/webp"
373 default:
374 return false, ""
375 }
376}
377
378// sniffImageMimeType returns the content-sniffed MIME type when it identifies
379// a supported image format. Otherwise it returns the provided fallback, which
380// is usually the extension-derived type. Providers that validate the image
381// media type against the base64 magic bytes (e.g. Anthropic) reject mismatched
382// requests with a 400, so trusting the filename alone is unsafe.
383func sniffImageMimeType(data []byte, fallback string) string {
384 sniffed := http.DetectContentType(data)
385 // http.DetectContentType may return the MIME with a ";" parameter
386 // (e.g. "image/svg+xml; charset=utf-8") although current image sniffers
387 // return bare types; strip defensively.
388 if i := strings.IndexByte(sniffed, ';'); i >= 0 {
389 sniffed = strings.TrimSpace(sniffed[:i])
390 }
391 switch sniffed {
392 case "image/jpeg", "image/png", "image/gif", "image/webp":
393 return sniffed
394 }
395 return fallback
396}
397
398// isInSkillsPath checks if filePath is within any of the configured skills
399// directories. Returns true for files that can be read without permission
400// prompts and without size limits.
401//
402// Note that symlinks are resolved to prevent path traversal attacks via
403// symbolic links.
404func isInSkillsPath(filePath string, skillsPaths []string) bool {
405 if len(skillsPaths) == 0 {
406 return false
407 }
408
409 absFilePath, err := filepath.Abs(filePath)
410 if err != nil {
411 return false
412 }
413
414 evalFilePath, err := filepath.EvalSymlinks(absFilePath)
415 if err != nil {
416 return false
417 }
418
419 for _, skillsPath := range skillsPaths {
420 absSkillsPath, err := filepath.Abs(skillsPath)
421 if err != nil {
422 continue
423 }
424
425 evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
426 if err != nil {
427 continue
428 }
429
430 relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
431 if err == nil && !strings.HasPrefix(relPath, "..") {
432 return true
433 }
434 }
435
436 return false
437}
438
439// readBuiltinFile reads a file from the embedded builtin skills filesystem.
440func readBuiltinFile(params ViewParams, skillTracker *skills.Tracker) (fantasy.ToolResponse, error) {
441 embeddedPath := "builtin/" + strings.TrimPrefix(params.FilePath, skills.BuiltinPrefix)
442 builtinFS := skills.BuiltinFS()
443
444 data, err := fs.ReadFile(builtinFS, embeddedPath)
445 if err != nil {
446 return fantasy.NewTextErrorResponse(fmt.Sprintf("Builtin file not found: %s", params.FilePath)), nil
447 }
448
449 content := string(data)
450 if !utf8.ValidString(content) {
451 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
452 }
453
454 limit := params.Limit
455 if limit <= 0 {
456 limit = 1000000 // Effectively no limit for skill files.
457 }
458
459 lines := strings.Split(content, "\n")
460 offset := min(params.Offset, len(lines))
461 lines = lines[offset:]
462
463 hasMore := len(lines) > limit
464 if hasMore {
465 lines = lines[:limit]
466 }
467
468 output := "<file>\n"
469 output += addLineNumbers(strings.Join(lines, "\n"), offset+1)
470 if hasMore {
471 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
472 offset+len(lines))
473 }
474 output += "\n</file>\n"
475
476 meta := ViewResponseMetadata{
477 FilePath: params.FilePath,
478 Content: strings.Join(lines, "\n"),
479 }
480 if skill, err := skills.ParseContent(data); err == nil {
481 meta.ResourceType = ViewResourceSkill
482 meta.ResourceName = skill.Name
483 meta.ResourceDescription = skill.Description
484 skillTracker.MarkLoaded(skill.Name)
485 }
486
487 return fantasy.WithResponseMetadata(
488 fantasy.NewTextResponse(output),
489 meta,
490 ), nil
491}