ls.go 6.7 KB

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