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