view.go 10 KB

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