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