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