view.go 12 KB

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