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