ls.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. package tools
  2. import (
  3. "cmp"
  4. "context"
  5. _ "embed"
  6. "fmt"
  7. "os"
  8. "path/filepath"
  9. "strings"
  10. "charm.land/fantasy"
  11. "github.com/charmbracelet/crush/internal/config"
  12. "github.com/charmbracelet/crush/internal/filepathext"
  13. "github.com/charmbracelet/crush/internal/fsext"
  14. "github.com/charmbracelet/crush/internal/permission"
  15. )
  16. type LSParams struct {
  17. Path string `json:"path,omitempty" description:"The path to the directory to list (defaults to current working directory)"`
  18. Ignore []string `json:"ignore,omitempty" description:"List of glob patterns to ignore"`
  19. Depth int `json:"depth,omitempty" description:"The maximum depth to traverse"`
  20. }
  21. type LSPermissionsParams struct {
  22. Path string `json:"path"`
  23. Ignore []string `json:"ignore"`
  24. Depth int `json:"depth"`
  25. }
  26. type TreeNode struct {
  27. Name string `json:"name"`
  28. Path string `json:"path"`
  29. Type string `json:"type"` // "file" or "directory"
  30. Children []*TreeNode `json:"children,omitempty"`
  31. }
  32. type LSResponseMetadata struct {
  33. NumberOfFiles int `json:"number_of_files"`
  34. Truncated bool `json:"truncated"`
  35. }
  36. const (
  37. LSToolName = "ls"
  38. maxLSFiles = 1000
  39. )
  40. //go:embed ls.md
  41. var lsDescription []byte
  42. func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool {
  43. return fantasy.NewAgentTool(
  44. LSToolName,
  45. string(lsDescription),
  46. func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
  47. searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir))
  48. if err != nil {
  49. return fantasy.NewTextErrorResponse(fmt.Sprintf("error expanding path: %v", err)), nil
  50. }
  51. searchPath = filepathext.SmartJoin(workingDir, searchPath)
  52. // Check if directory is outside working directory and request permission if needed
  53. absWorkingDir, err := filepath.Abs(workingDir)
  54. if err != nil {
  55. return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving working directory: %v", err)), nil
  56. }
  57. absSearchPath, err := filepath.Abs(searchPath)
  58. if err != nil {
  59. return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving search path: %v", err)), nil
  60. }
  61. relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
  62. if err != nil || strings.HasPrefix(relPath, "..") {
  63. // Directory is outside working directory, request permission
  64. sessionID := GetSessionFromContext(ctx)
  65. if sessionID == "" {
  66. return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
  67. }
  68. granted := permissions.Request(
  69. permission.CreatePermissionRequest{
  70. SessionID: sessionID,
  71. Path: absSearchPath,
  72. ToolCallID: call.ID,
  73. ToolName: LSToolName,
  74. Action: "list",
  75. Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
  76. Params: LSPermissionsParams(params),
  77. },
  78. )
  79. if !granted {
  80. return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
  81. }
  82. }
  83. output, metadata, err := ListDirectoryTree(searchPath, params, lsConfig)
  84. if err != nil {
  85. return fantasy.NewTextErrorResponse(err.Error()), err
  86. }
  87. return fantasy.WithResponseMetadata(
  88. fantasy.NewTextResponse(output),
  89. metadata,
  90. ), nil
  91. })
  92. }
  93. func ListDirectoryTree(searchPath string, params LSParams, lsConfig config.ToolLs) (string, LSResponseMetadata, error) {
  94. if _, err := os.Stat(searchPath); os.IsNotExist(err) {
  95. return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
  96. }
  97. depth, limit := lsConfig.Limits()
  98. maxFiles := cmp.Or(limit, maxLSFiles)
  99. files, truncated, err := fsext.ListDirectory(
  100. searchPath,
  101. params.Ignore,
  102. cmp.Or(params.Depth, depth),
  103. maxFiles,
  104. )
  105. if err != nil {
  106. return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
  107. }
  108. metadata := LSResponseMetadata{
  109. NumberOfFiles: len(files),
  110. Truncated: truncated,
  111. }
  112. tree := createFileTree(files, searchPath)
  113. var output string
  114. if truncated {
  115. output = fmt.Sprintf("There are more than %d files in the directory. Use a more specific path or use the Glob tool to find specific files. The first %[1]d files and directories are included below.\n", maxFiles)
  116. }
  117. if depth > 0 {
  118. output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
  119. }
  120. return output + "\n" + printTree(tree, searchPath), metadata, nil
  121. }
  122. func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
  123. root := []*TreeNode{}
  124. pathMap := make(map[string]*TreeNode)
  125. for _, path := range sortedPaths {
  126. relativePath := strings.TrimPrefix(path, rootPath)
  127. parts := strings.Split(relativePath, string(filepath.Separator))
  128. currentPath := ""
  129. var parentPath string
  130. var cleanParts []string
  131. for _, part := range parts {
  132. if part != "" {
  133. cleanParts = append(cleanParts, part)
  134. }
  135. }
  136. parts = cleanParts
  137. if len(parts) == 0 {
  138. continue
  139. }
  140. for i, part := range parts {
  141. if currentPath == "" {
  142. currentPath = part
  143. } else {
  144. currentPath = filepath.Join(currentPath, part)
  145. }
  146. if _, exists := pathMap[currentPath]; exists {
  147. parentPath = currentPath
  148. continue
  149. }
  150. isLastPart := i == len(parts)-1
  151. isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
  152. nodeType := "file"
  153. if isDir {
  154. nodeType = "directory"
  155. }
  156. newNode := &TreeNode{
  157. Name: part,
  158. Path: currentPath,
  159. Type: nodeType,
  160. Children: []*TreeNode{},
  161. }
  162. pathMap[currentPath] = newNode
  163. if i > 0 && parentPath != "" {
  164. if parent, ok := pathMap[parentPath]; ok {
  165. parent.Children = append(parent.Children, newNode)
  166. }
  167. } else {
  168. root = append(root, newNode)
  169. }
  170. parentPath = currentPath
  171. }
  172. }
  173. return root
  174. }
  175. func printTree(tree []*TreeNode, rootPath string) string {
  176. var result strings.Builder
  177. result.WriteString("- ")
  178. result.WriteString(filepath.ToSlash(rootPath))
  179. if rootPath[len(rootPath)-1] != '/' {
  180. result.WriteByte('/')
  181. }
  182. result.WriteByte('\n')
  183. for _, node := range tree {
  184. printNode(&result, node, 1)
  185. }
  186. return result.String()
  187. }
  188. func printNode(builder *strings.Builder, node *TreeNode, level int) {
  189. indent := strings.Repeat(" ", level)
  190. nodeName := node.Name
  191. if node.Type == "directory" {
  192. nodeName = nodeName + "/"
  193. }
  194. fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
  195. if node.Type == "directory" && len(node.Children) > 0 {
  196. for _, child := range node.Children {
  197. printNode(builder, child, level+1)
  198. }
  199. }
  200. }