1package tools
2
3import (
4 "bufio"
5 "context"
6 _ "embed"
7 "encoding/base64"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "strings"
13 "unicode/utf8"
14
15 "charm.land/fantasy"
16 "github.com/charmbracelet/crush/internal/filepathext"
17 "github.com/charmbracelet/crush/internal/filetracker"
18 "github.com/charmbracelet/crush/internal/lsp"
19 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/skills"
21)
22
23//go:embed view.md
24var viewDescription []byte
25
26type ViewParams struct {
27 FilePath string `json:"file_path" description:"The path to the file to read"`
28 Offset int `json:"offset,omitempty" description:"The line number to start reading from (0-based)"`
29 Limit int `json:"limit,omitempty" description:"The number of lines to read (defaults to 2000)"`
30}
31
32type ViewPermissionsParams struct {
33 FilePath string `json:"file_path"`
34 Offset int `json:"offset"`
35 Limit int `json:"limit"`
36}
37
38type ViewResourceType string
39
40const (
41 ViewResourceUnset ViewResourceType = ""
42 ViewResourceSkill ViewResourceType = "skill"
43)
44
45type ViewResponseMetadata struct {
46 FilePath string `json:"file_path"`
47 Content string `json:"content"`
48 ResourceType ViewResourceType `json:"resource_type,omitempty"`
49 ResourceName string `json:"resource_name,omitempty"`
50 ResourceDescription string `json:"resource_description,omitempty"`
51}
52
53const (
54 ViewToolName = "view"
55 MaxReadSize = 5 * 1024 * 1024 // 5MB
56 DefaultReadLimit = 2000
57 MaxLineLength = 2000
58)
59
60func NewViewTool(
61 lspManager *lsp.Manager,
62 permissions permission.Service,
63 filetracker filetracker.Service,
64 workingDir string,
65 skillsPaths ...string,
66) fantasy.AgentTool {
67 return fantasy.NewAgentTool(
68 ViewToolName,
69 string(viewDescription),
70 func(ctx context.Context, params ViewParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
71 if params.FilePath == "" {
72 return fantasy.NewTextErrorResponse("file_path is required"), nil
73 }
74
75 // Handle relative paths
76 filePath := filepathext.SmartJoin(workingDir, params.FilePath)
77
78 // Check if file is outside working directory and request permission if needed
79 absWorkingDir, err := filepath.Abs(workingDir)
80 if err != nil {
81 return fantasy.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
82 }
83
84 absFilePath, err := filepath.Abs(filePath)
85 if err != nil {
86 return fantasy.ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
87 }
88
89 relPath, err := filepath.Rel(absWorkingDir, absFilePath)
90 isOutsideWorkDir := err != nil || strings.HasPrefix(relPath, "..")
91 isSkillFile := isInSkillsPath(absFilePath, skillsPaths)
92
93 sessionID := GetSessionFromContext(ctx)
94 if sessionID == "" {
95 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
96 }
97
98 // Request permission for files outside working directory, unless it's a skill file.
99 if isOutsideWorkDir && !isSkillFile {
100 granted, err := permissions.Request(ctx,
101 permission.CreatePermissionRequest{
102 SessionID: sessionID,
103 Path: absFilePath,
104 ToolCallID: call.ID,
105 ToolName: ViewToolName,
106 Action: "read",
107 Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
108 Params: ViewPermissionsParams(params),
109 },
110 )
111 if err != nil {
112 return fantasy.ToolResponse{}, err
113 }
114 if !granted {
115 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
116 }
117 }
118
119 // Check if file exists
120 fileInfo, err := os.Stat(filePath)
121 if err != nil {
122 if os.IsNotExist(err) {
123 // Try to offer suggestions for similarly named files
124 dir := filepath.Dir(filePath)
125 base := filepath.Base(filePath)
126
127 dirEntries, dirErr := os.ReadDir(dir)
128 if dirErr == nil {
129 var suggestions []string
130 for _, entry := range dirEntries {
131 if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
132 strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
133 suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
134 if len(suggestions) >= 3 {
135 break
136 }
137 }
138 }
139
140 if len(suggestions) > 0 {
141 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
142 filePath, strings.Join(suggestions, "\n"))), nil
143 }
144 }
145
146 return fantasy.NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
147 }
148 return fantasy.ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
149 }
150
151 // Check if it's a directory
152 if fileInfo.IsDir() {
153 return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
154 }
155
156 // Based on the specifications we should not limit the skills read.
157 if !isSkillFile && fileInfo.Size() > MaxReadSize {
158 return fantasy.NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
159 fileInfo.Size(), MaxReadSize)), nil
160 }
161
162 // Set default limit if not provided (no limit for SKILL.md files)
163 if params.Limit <= 0 {
164 if isSkillFile {
165 params.Limit = 1000000 // Effectively no limit for skill files
166 } else {
167 params.Limit = DefaultReadLimit
168 }
169 }
170
171 isSupportedImage, mimeType := getImageMimeType(filePath)
172 if isSupportedImage {
173 if !GetSupportsImagesFromContext(ctx) {
174 modelName := GetModelNameFromContext(ctx)
175 return fantasy.NewTextErrorResponse(fmt.Sprintf("This model (%s) does not support image data.", modelName)), nil
176 }
177
178 imageData, err := os.ReadFile(filePath)
179 if err != nil {
180 return fantasy.ToolResponse{}, fmt.Errorf("error reading image file: %w", err)
181 }
182
183 encoded := base64.StdEncoding.EncodeToString(imageData)
184 return fantasy.NewImageResponse([]byte(encoded), mimeType), nil
185 }
186
187 // Read the file content
188 content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
189 if err != nil {
190 return fantasy.ToolResponse{}, fmt.Errorf("error reading file: %w", err)
191 }
192 isValidUt8 := utf8.ValidString(content)
193 if !isValidUt8 {
194 return fantasy.NewTextErrorResponse("File content is not valid UTF-8"), nil
195 }
196
197 notifyLSPs(ctx, lspManager, filePath)
198 output := "<file>\n"
199 // Format the output with line numbers
200 output += addLineNumbers(content, params.Offset+1)
201
202 // Add a note if the content was truncated
203 if lineCount > params.Offset+len(strings.Split(content, "\n")) {
204 output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
205 params.Offset+len(strings.Split(content, "\n")))
206 }
207 output += "\n</file>\n"
208 output += getDiagnostics(filePath, lspManager)
209 filetracker.RecordRead(ctx, sessionID, filePath)
210
211 meta := ViewResponseMetadata{
212 FilePath: filePath,
213 Content: content,
214 }
215 if isSkillFile {
216 if skill, err := skills.Parse(filePath); err == nil {
217 meta.ResourceType = ViewResourceSkill
218 meta.ResourceName = skill.Name
219 meta.ResourceDescription = skill.Description
220 }
221 }
222
223 return fantasy.WithResponseMetadata(
224 fantasy.NewTextResponse(output),
225 meta,
226 ), nil
227 })
228}
229
230func addLineNumbers(content string, startLine int) string {
231 if content == "" {
232 return ""
233 }
234
235 lines := strings.Split(content, "\n")
236
237 var result []string
238 for i, line := range lines {
239 line = strings.TrimSuffix(line, "\r")
240
241 lineNum := i + startLine
242 numStr := fmt.Sprintf("%d", lineNum)
243
244 if len(numStr) >= 6 {
245 result = append(result, fmt.Sprintf("%s|%s", numStr, line))
246 } else {
247 paddedNum := fmt.Sprintf("%6s", numStr)
248 result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
249 }
250 }
251
252 return strings.Join(result, "\n")
253}
254
255func readTextFile(filePath string, offset, limit int) (string, int, error) {
256 file, err := os.Open(filePath)
257 if err != nil {
258 return "", 0, err
259 }
260 defer file.Close()
261
262 lineCount := 0
263
264 scanner := NewLineScanner(file)
265 if offset > 0 {
266 for lineCount < offset && scanner.Scan() {
267 lineCount++
268 }
269 if err = scanner.Err(); err != nil {
270 return "", 0, err
271 }
272 }
273
274 if offset == 0 {
275 _, err = file.Seek(0, io.SeekStart)
276 if err != nil {
277 return "", 0, err
278 }
279 }
280
281 // Pre-allocate slice with expected capacity
282 lines := make([]string, 0, limit)
283 lineCount = offset
284
285 for scanner.Scan() && len(lines) < limit {
286 lineCount++
287 lineText := scanner.Text()
288 if len(lineText) > MaxLineLength {
289 lineText = lineText[:MaxLineLength] + "..."
290 }
291 lines = append(lines, lineText)
292 }
293
294 // Continue scanning to get total line count
295 for scanner.Scan() {
296 lineCount++
297 }
298
299 if err := scanner.Err(); err != nil {
300 return "", 0, err
301 }
302
303 return strings.Join(lines, "\n"), lineCount, nil
304}
305
306func getImageMimeType(filePath string) (bool, string) {
307 ext := strings.ToLower(filepath.Ext(filePath))
308 switch ext {
309 case ".jpg", ".jpeg":
310 return true, "image/jpeg"
311 case ".png":
312 return true, "image/png"
313 case ".gif":
314 return true, "image/gif"
315 case ".webp":
316 return true, "image/webp"
317 default:
318 return false, ""
319 }
320}
321
322type LineScanner struct {
323 scanner *bufio.Scanner
324}
325
326func NewLineScanner(r io.Reader) *LineScanner {
327 scanner := bufio.NewScanner(r)
328 // Increase buffer size to handle large lines (e.g., minified JSON, HTML)
329 // Default is 64KB, set to 1MB
330 buf := make([]byte, 0, 64*1024)
331 scanner.Buffer(buf, 1024*1024)
332 return &LineScanner{
333 scanner: scanner,
334 }
335}
336
337func (s *LineScanner) Scan() bool {
338 return s.scanner.Scan()
339}
340
341func (s *LineScanner) Text() string {
342 return s.scanner.Text()
343}
344
345func (s *LineScanner) Err() error {
346 return s.scanner.Err()
347}
348
349// isInSkillsPath checks if filePath is within any of the configured skills
350// directories. Returns true for files that can be read without permission
351// prompts and without size limits.
352//
353// Note that symlinks are resolved to prevent path traversal attacks via
354// symbolic links.
355func isInSkillsPath(filePath string, skillsPaths []string) bool {
356 if len(skillsPaths) == 0 {
357 return false
358 }
359
360 absFilePath, err := filepath.Abs(filePath)
361 if err != nil {
362 return false
363 }
364
365 evalFilePath, err := filepath.EvalSymlinks(absFilePath)
366 if err != nil {
367 return false
368 }
369
370 for _, skillsPath := range skillsPaths {
371 absSkillsPath, err := filepath.Abs(skillsPath)
372 if err != nil {
373 continue
374 }
375
376 evalSkillsPath, err := filepath.EvalSymlinks(absSkillsPath)
377 if err != nil {
378 continue
379 }
380
381 relPath, err := filepath.Rel(evalSkillsPath, evalFilePath)
382 if err == nil && !strings.HasPrefix(relPath, "..") {
383 return true
384 }
385 }
386
387 return false
388}