view.go 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. package tools
  2. import (
  3. "bufio"
  4. "context"
  5. _ "embed"
  6. "encoding/json"
  7. "fmt"
  8. "io"
  9. "os"
  10. "path/filepath"
  11. "strings"
  12. "unicode/utf8"
  13. "github.com/charmbracelet/crush/internal/csync"
  14. "github.com/charmbracelet/crush/internal/lsp"
  15. "github.com/charmbracelet/crush/internal/permission"
  16. )
  17. //go:embed view.md
  18. var viewDescription []byte
  19. type ViewParams struct {
  20. FilePath string `json:"file_path"`
  21. Offset int `json:"offset"`
  22. Limit int `json:"limit"`
  23. }
  24. type ViewPermissionsParams struct {
  25. FilePath string `json:"file_path"`
  26. Offset int `json:"offset"`
  27. Limit int `json:"limit"`
  28. }
  29. type viewTool struct {
  30. lspClients *csync.Map[string, *lsp.Client]
  31. workingDir string
  32. permissions permission.Service
  33. }
  34. type ViewResponseMetadata struct {
  35. FilePath string `json:"file_path"`
  36. Content string `json:"content"`
  37. }
  38. const (
  39. ViewToolName = "view"
  40. MaxReadSize = 250 * 1024
  41. DefaultReadLimit = 2000
  42. MaxLineLength = 2000
  43. )
  44. func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, workingDir string) BaseTool {
  45. return &viewTool{
  46. lspClients: lspClients,
  47. workingDir: workingDir,
  48. permissions: permissions,
  49. }
  50. }
  51. func (v *viewTool) Name() string {
  52. return ViewToolName
  53. }
  54. func (v *viewTool) Info() ToolInfo {
  55. return ToolInfo{
  56. Name: ViewToolName,
  57. Description: string(viewDescription),
  58. Parameters: map[string]any{
  59. "file_path": map[string]any{
  60. "type": "string",
  61. "description": "The path to the file to read",
  62. },
  63. "offset": map[string]any{
  64. "type": "integer",
  65. "description": "The line number to start reading from (0-based)",
  66. },
  67. "limit": map[string]any{
  68. "type": "integer",
  69. "description": "The number of lines to read (defaults to 2000)",
  70. },
  71. },
  72. Required: []string{"file_path"},
  73. }
  74. }
  75. // Run implements Tool.
  76. func (v *viewTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
  77. var params ViewParams
  78. if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
  79. return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
  80. }
  81. if params.FilePath == "" {
  82. return NewTextErrorResponse("file_path is required"), nil
  83. }
  84. // Handle relative paths
  85. filePath := params.FilePath
  86. if !filepath.IsAbs(filePath) {
  87. filePath = filepath.Join(v.workingDir, filePath)
  88. }
  89. // Check if file is outside working directory and request permission if needed
  90. absWorkingDir, err := filepath.Abs(v.workingDir)
  91. if err != nil {
  92. return ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
  93. }
  94. absFilePath, err := filepath.Abs(filePath)
  95. if err != nil {
  96. return ToolResponse{}, fmt.Errorf("error resolving file path: %w", err)
  97. }
  98. relPath, err := filepath.Rel(absWorkingDir, absFilePath)
  99. if err != nil || strings.HasPrefix(relPath, "..") {
  100. // File is outside working directory, request permission
  101. sessionID, messageID := GetContextValues(ctx)
  102. if sessionID == "" || messageID == "" {
  103. return ToolResponse{}, fmt.Errorf("session ID and message ID are required for accessing files outside working directory")
  104. }
  105. granted := v.permissions.Request(
  106. permission.CreatePermissionRequest{
  107. SessionID: sessionID,
  108. Path: absFilePath,
  109. ToolCallID: call.ID,
  110. ToolName: ViewToolName,
  111. Action: "read",
  112. Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
  113. Params: ViewPermissionsParams(params),
  114. },
  115. )
  116. if !granted {
  117. return ToolResponse{}, permission.ErrorPermissionDenied
  118. }
  119. }
  120. // Check if file exists
  121. fileInfo, err := os.Stat(filePath)
  122. if err != nil {
  123. if os.IsNotExist(err) {
  124. // Try to offer suggestions for similarly named files
  125. dir := filepath.Dir(filePath)
  126. base := filepath.Base(filePath)
  127. dirEntries, dirErr := os.ReadDir(dir)
  128. if dirErr == nil {
  129. var suggestions []string
  130. for _, entry := range dirEntries {
  131. if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(base)) ||
  132. strings.Contains(strings.ToLower(base), strings.ToLower(entry.Name())) {
  133. suggestions = append(suggestions, filepath.Join(dir, entry.Name()))
  134. if len(suggestions) >= 3 {
  135. break
  136. }
  137. }
  138. }
  139. if len(suggestions) > 0 {
  140. return NewTextErrorResponse(fmt.Sprintf("File not found: %s\n\nDid you mean one of these?\n%s",
  141. filePath, strings.Join(suggestions, "\n"))), nil
  142. }
  143. }
  144. return NewTextErrorResponse(fmt.Sprintf("File not found: %s", filePath)), nil
  145. }
  146. return ToolResponse{}, fmt.Errorf("error accessing file: %w", err)
  147. }
  148. // Check if it's a directory
  149. if fileInfo.IsDir() {
  150. return NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
  151. }
  152. // Check file size
  153. if fileInfo.Size() > MaxReadSize {
  154. return NewTextErrorResponse(fmt.Sprintf("File is too large (%d bytes). Maximum size is %d bytes",
  155. fileInfo.Size(), MaxReadSize)), nil
  156. }
  157. // Set default limit if not provided
  158. if params.Limit <= 0 {
  159. params.Limit = DefaultReadLimit
  160. }
  161. // Check if it's an image file
  162. isImage, imageType := isImageFile(filePath)
  163. // TODO: handle images
  164. if isImage {
  165. return NewTextErrorResponse(fmt.Sprintf("This is an image file of type: %s\n", imageType)), nil
  166. }
  167. // Read the file content
  168. content, lineCount, err := readTextFile(filePath, params.Offset, params.Limit)
  169. isValidUt8 := utf8.ValidString(content)
  170. if !isValidUt8 {
  171. return NewTextErrorResponse("File content is not valid UTF-8"), nil
  172. }
  173. if err != nil {
  174. return ToolResponse{}, fmt.Errorf("error reading file: %w", err)
  175. }
  176. notifyLSPs(ctx, v.lspClients, filePath)
  177. output := "<file>\n"
  178. // Format the output with line numbers
  179. output += addLineNumbers(content, params.Offset+1)
  180. // Add a note if the content was truncated
  181. if lineCount > params.Offset+len(strings.Split(content, "\n")) {
  182. output += fmt.Sprintf("\n\n(File has more lines. Use 'offset' parameter to read beyond line %d)",
  183. params.Offset+len(strings.Split(content, "\n")))
  184. }
  185. output += "\n</file>\n"
  186. output += getDiagnostics(filePath, v.lspClients)
  187. recordFileRead(filePath)
  188. return WithResponseMetadata(
  189. NewTextResponse(output),
  190. ViewResponseMetadata{
  191. FilePath: filePath,
  192. Content: content,
  193. },
  194. ), nil
  195. }
  196. func addLineNumbers(content string, startLine int) string {
  197. if content == "" {
  198. return ""
  199. }
  200. lines := strings.Split(content, "\n")
  201. var result []string
  202. for i, line := range lines {
  203. line = strings.TrimSuffix(line, "\r")
  204. lineNum := i + startLine
  205. numStr := fmt.Sprintf("%d", lineNum)
  206. if len(numStr) >= 6 {
  207. result = append(result, fmt.Sprintf("%s|%s", numStr, line))
  208. } else {
  209. paddedNum := fmt.Sprintf("%6s", numStr)
  210. result = append(result, fmt.Sprintf("%s|%s", paddedNum, line))
  211. }
  212. }
  213. return strings.Join(result, "\n")
  214. }
  215. func readTextFile(filePath string, offset, limit int) (string, int, error) {
  216. file, err := os.Open(filePath)
  217. if err != nil {
  218. return "", 0, err
  219. }
  220. defer file.Close()
  221. lineCount := 0
  222. scanner := NewLineScanner(file)
  223. if offset > 0 {
  224. for lineCount < offset && scanner.Scan() {
  225. lineCount++
  226. }
  227. if err = scanner.Err(); err != nil {
  228. return "", 0, err
  229. }
  230. }
  231. if offset == 0 {
  232. _, err = file.Seek(0, io.SeekStart)
  233. if err != nil {
  234. return "", 0, err
  235. }
  236. }
  237. // Pre-allocate slice with expected capacity
  238. lines := make([]string, 0, limit)
  239. lineCount = offset
  240. for scanner.Scan() && len(lines) < limit {
  241. lineCount++
  242. lineText := scanner.Text()
  243. if len(lineText) > MaxLineLength {
  244. lineText = lineText[:MaxLineLength] + "..."
  245. }
  246. lines = append(lines, lineText)
  247. }
  248. // Continue scanning to get total line count
  249. for scanner.Scan() {
  250. lineCount++
  251. }
  252. if err := scanner.Err(); err != nil {
  253. return "", 0, err
  254. }
  255. return strings.Join(lines, "\n"), lineCount, nil
  256. }
  257. func isImageFile(filePath string) (bool, string) {
  258. ext := strings.ToLower(filepath.Ext(filePath))
  259. switch ext {
  260. case ".jpg", ".jpeg":
  261. return true, "JPEG"
  262. case ".png":
  263. return true, "PNG"
  264. case ".gif":
  265. return true, "GIF"
  266. case ".bmp":
  267. return true, "BMP"
  268. case ".svg":
  269. return true, "SVG"
  270. case ".webp":
  271. return true, "WebP"
  272. default:
  273. return false, ""
  274. }
  275. }
  276. type LineScanner struct {
  277. scanner *bufio.Scanner
  278. }
  279. func NewLineScanner(r io.Reader) *LineScanner {
  280. return &LineScanner{
  281. scanner: bufio.NewScanner(r),
  282. }
  283. }
  284. func (s *LineScanner) Scan() bool {
  285. return s.scanner.Scan()
  286. }
  287. func (s *LineScanner) Text() string {
  288. return s.scanner.Text()
  289. }
  290. func (s *LineScanner) Err() error {
  291. return s.scanner.Err()
  292. }