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