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 200)"`
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 = 200
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(
138 ctx,
139 permission.CreatePermissionRequest{
140 SessionID: sessionID,
141 Path: absFilePath,
142 ToolCallID: call.ID,
143 ToolName: ViewToolName,
144 Action: "read",
145 Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
146 Params: ViewPermissionsParams(params),
147 },
148 )
149 if permReqErr != nil {
150 return fantasy.ToolResponse{}, permReqErr
151 }
152 if !granted {
153 return NewPermissionDeniedResponse(), nil
154 }
155 }
156
157 // Check if file exists
158 fileInfo, err := os.Stat(filePath)
159 if err != nil {
160 if os.IsNotExist(err) {
161 // Try to offer suggestions for similarly named files
162 dir := filepath.Dir(filePath)
163 base := filepath.Base(filePath)
164
165 dirEntries, dirErr := os.ReadDir(dir)
166 if dirErr == nil {
167 var suggestions []string
168 for _, entry := range dirEntries {
169 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
170 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
171 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
172 if len(suggestions) >= 3 {
173 break
174 }
175 }
176 }
177
178 if len(suggestions) > 0 {
179 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
180 filePath, strings.Join(suggestions, "\n"))), nil
181 }
182 }
183
184 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
185 }
186 return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
187 }
188
189 // Check if it's a directory
190 if fileInfo.IsDir() {
191 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
192 }
193
194 // Set default limit if not provided (no limit for SKILL.md files)
195 if params.Limit <= 0 {
196 if isSkillFile {
197 params.Limit = 1000000 // Effectively no limit for skill files
198 } else {
199 params.Limit = DefaultReadLimit
200 }
201 }
202
203 isSupportedImage, mimeType := getImageMimeType(filePath)
204 if isSupportedImage {
205 if fileInfo.Size() > MaxViewSize {
206 return fantasy.NewTextErrorResponse(fmt.Sprintf("Image file is too large (%d bytes). Maximum size is %d bytes",
207 fileInfo.Size(), MaxViewSize)), nil
208 }
209 if !GetSupportsImagesFromContext(ctx) {
210 modelName := GetModelNameFromContext(ctx)
211 return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
212 }
213
214 imageData, readErr := os.ReadFile(filePath)
215 if readErr != nil {
216 return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", readErr)
217 }
218
219 // Some tools save files with a mismatched extension
220 // (e.g. pinchtab writes JPEG bytes to a .png file).
221 // Providers like Anthropic strictly validate the
222 // media type against the base64 magic bytes and 400
223 // on mismatch, so prefer the sniffed type whenever
224 // it identifies a supported image format.
225 mimeType = sniffImageMimeType(imageData, mimeType)
226
227 return fantasy.NewImageResponse(imageData, mimeType), nil
228 }
229
230 // Read the file content
231 maxContentSize := MaxViewSize
232 if isSkillFile {
233 maxContentSize = 0
234 }
235 content, hasMore, err := readTextFile(filePath, params.Offset, params.Limit, maxContentSize)
236 if err != nil {
237 var tooLarge contentTooLargeError
238 if errors.As(err, &tooLarge) {
239 return fantasy.NewTextErrorResponse(fmt.Sprintf("Content section is too large (%d bytes). Maximum size is %d bytes",
240 tooLarge.Size, tooLarge.Max)), nil
241 }
242 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
243 }
244 if !utf8.ValidString(content) {
245 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
246 }
247
248 openInLSPs(ctx, lspManager, filePath)
249 waitForLSPDiagnostics(ctx, lspManager, filePath, 300*time.Millisecond)
250 output := "<file>\n"
251 output += addLineNumbers(content, params.Offset+1)
252
253 if hasMore {
254 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
255 params.Offset+len(strings.Split(content, "\n")))
256 }
257 output += "\n</file>\n"
258 output += getDiagnostics(filePath, lspManager)
259 filetracker.RecordRead(ctx, sessionID, filePath)
260
261 meta := ViewResponseMetadata{
262 FilePath: filePath,
263 Content: content,
264 }
265 if isSkillFile {
266 if skill, err := skills.Parse(filePath); err == nil {
267 meta.ResourceType = ViewResourceSkill
268 meta.ResourceName = skill.Name
269 meta.ResourceDescription = skill.Description
270 skillTracker.MarkLoaded(skill.Name)
271 }
272 }
273
274 return fantasy.WithResponseMetadata(
275 fantasy.NewTextResponse(output),
276 meta,
277 ), nil
278 },
279 )
280}
281
282func addLineNumbers(content string, startLine int) string {
283 if content == "" {
284 return ""
285 }
286
287 lines := strings.Split(content, "\n")
288
289 var result []string
290 for i, line := range lines {
291 line = strings.TrimSuffix(line, "\r")
292
293 lineNum := i + startLine
294 numStr := fmt.Sprintf("%d", lineNum)
295
296 if len(numStr) >= 6 {
297 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
298 } else {
299 paddedNum := fmt.Sprintf("%6s", numStr)
300 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
301 }
302 }
303
304 return strings.Join(result, "\n")
305}
306
307func readTextFile(filePath string, offset, limit, maxContentSize int) (string, bool, error) {
308 file, err := os.Open(filePath)
309 if err != nil {
310 return "", false, err
311 }
312 defer file.Close()
313
314 reader := bufio.NewReader(file)
315 skipped := 0
316 for skipped < offset {
317 _, err := reader.ReadString('\n')
318 if err != nil {
319 if err == io.EOF {
320 return "", false, nil
321 }
322 return "", false, err
323 }
324 skipped++
325 }
326
327 lines := make([]string, 0, min(limit, DefaultReadLimit))
328 contentSize := 0
329
330 for len(lines) < limit {
331 lineText, err := reader.ReadString('\n')
332 if err != nil && err != io.EOF {
333 return "", false, err
334 }
335 lineText = strings.TrimSuffix(lineText, "\n")
336 lineText = strings.TrimSuffix(lineText, "\r")
337 if len(lineText) > MaxLineLength {
338 lineText = lineText[:MaxLineLength] + "..."
339 }
340 projectedSize := contentSize + len(lineText)
341 if len(lines) > 0 {
342 projectedSize++
343 }
344 if maxContentSize > 0 && projectedSize > maxContentSize {
345 return "", false, contentTooLargeError{Size: projectedSize, Max: maxContentSize}
346 }
347 contentSize = projectedSize
348 lines = append(lines, lineText)
349 if err == io.EOF {
350 break
351 }
352 }
353
354 // Peek one more line only when we filled the limit.
355 hasMore := false
356 if len(lines) == limit {
357 lineText, peekErr := reader.ReadString('\n')
358 hasMore = len(lineText) > 0 || peekErr == nil
359 }
360
361 return strings.Join(lines, "\n"), hasMore, nil
362}
363
364func getImageMimeType(filePath string) (bool, string) {
365 ext := strings.ToLower(filepath.Ext(filePath))
366 switch ext {
367 case ".jpg", ".jpeg":
368 return true, "image/jpeg"
369 case ".png":
370 return true, "image/png"
371 case ".gif":
372 return true, "image/gif"
373 case ".webp":
374 return true, "image/webp"
375 default:
376 return false, ""
377 }
378}
379
380// sniffImageMimeType returns the content-sniffed MIME type when it identifies
381// a supported image format. Otherwise it returns the provided fallback, which
382// is usually the extension-derived type. Providers that validate the image
383// media type against the base64 magic bytes (e.g. Anthropic) reject mismatched
384// requests with a 400, so trusting the filename alone is unsafe.
385func sniffImageMimeType(data []byte, fallback string) string {
386 sniffed := http.DetectContentType(data)
387 // http.DetectContentType may return the MIME with a ";" parameter
388 // (e.g. "image/svg+xml; charset=utf-8") although current image sniffers
389 // return bare types; strip defensively.
390 if i := strings.IndexByte(sniffed, ';'); i >= 0 {
391 sniffed = strings.TrimSpace(sniffed[:i])
392 }
393 switch sniffed {
394 case "image/jpeg", "image/png", "image/gif", "image/webp":
395 return sniffed
396 }
397 return fallback
398}
399
400// isInSkillsPath checks if filePath is within any of the configured skills
401// directories. Returns true for files that can be read without permission
402// prompts and without size limits.
403//
404// Note that symlinks are resolved to prevent path traversal attacks via
405// symbolic links.
406func isInSkillsPath(filePath string, skillsPaths []string) bool {
407 if len(skillsPaths) == 0 {
408 return false
409 }
410
411 absFilePath, err := filepath.Abs(filePath)
412 if err != nil {
413 return false
414 }
415
416 evalFilePath, err := filepath.EvalSymlinks(absFilePath)
417 if err != nil {
418 return false
419 }
420
421 for _, skillsPath := range skillsPaths {
422 absSkillsPath, err := filepath.Abs(skillsPath)
423 if err != nil {
424 continue
425 }
426
427 evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
428 if err != nil {
429 continue
430 }
431
432 relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
433 if err == nil && !strings.HasPrefix(relPath, "..") {
434 return true
435 }
436 }
437
438 return false
439}
440
441// readBuiltinFile reads a file from the embedded builtin skills filesystem.
442func readBuiltinFile(params ViewParams, skillTracker *skills.Tracker) (fantasy.ToolResponse, error) {
443 embeddedPath := "builtin/" + strings.TrimPrefix(params.FilePath, skills.BuiltinPrefix)
444 builtinFS := skills.BuiltinFS()
445
446 data, err := fs.ReadFile(builtinFS, embeddedPath)
447 if err != nil {
448 return fantasy.NewTextErrorResponse(fmt.Sprintf("Builtin file not found: %s", params.FilePath)), nil
449 }
450
451 content := string(data)
452 if !utf8.ValidString(content) {
453 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
454 }
455
456 limit := params.Limit
457 if limit <= 0 {
458 limit = 1000000 // Effectively no limit for skill files.
459 }
460
461 lines := strings.Split(content, "\n")
462 offset := min(params.Offset, len(lines))
463 lines = lines[offset:]
464
465 hasMore := len(lines) > limit
466 if hasMore {
467 lines = lines[:limit]
468 }
469
470 output := "<file>\n"
471 output += addLineNumbers(strings.Join(lines, "\n"), offset+1)
472 if hasMore {
473 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
474 offset+len(lines))
475 }
476 output += "\n</file>\n"
477
478 meta := ViewResponseMetadata{
479 FilePath: params.FilePath,
480 Content: strings.Join(lines, "\n"),
481 }
482 if skill, err := skills.ParseContent(data); err == nil {
483 meta.ResourceType = ViewResourceSkill
484 meta.ResourceName = skill.Name
485 meta.ResourceDescription = skill.Description
486 skillTracker.MarkLoaded(skill.Name)
487 }
488
489 return fantasy.WithResponseMetadata(
490 fantasy.NewTextResponse(output),
491 meta,
492 ), nil
493}