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