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