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