| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- package tools
- import (
- "cmp"
- "context"
- _ "embed"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "charm.land/fantasy"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/filepathext"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/permission"
- )
- type LSParams struct {
- Path string `json:"path,omitempty" description:"The path to the directory to list (defaults to current working directory)"`
- Ignore []string `json:"ignore,omitempty" description:"List of glob patterns to ignore"`
- Depth int `json:"depth,omitempty" description:"The maximum depth to traverse"`
- }
- type LSPermissionsParams struct {
- Path string `json:"path"`
- Ignore []string `json:"ignore"`
- Depth int `json:"depth"`
- }
- type TreeNode struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Type string `json:"type"` // "file" or "directory"
- Children []*TreeNode `json:"children,omitempty"`
- }
- type LSResponseMetadata struct {
- NumberOfFiles int `json:"number_of_files"`
- Truncated bool `json:"truncated"`
- }
- const (
- LSToolName = "ls"
- maxLSFiles = 1000
- )
- //go:embed ls.md
- var lsDescription []byte
- func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool {
- return fantasy.NewAgentTool(
- LSToolName,
- string(lsDescription),
- func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
- searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir))
- if err != nil {
- return fantasy.NewTextErrorResponse(fmt.Sprintf("error expanding path: %v", err)), nil
- }
- searchPath = filepathext.SmartJoin(workingDir, searchPath)
- // Check if directory is outside working directory and request permission if needed
- absWorkingDir, err := filepath.Abs(workingDir)
- if err != nil {
- return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving working directory: %v", err)), nil
- }
- absSearchPath, err := filepath.Abs(searchPath)
- if err != nil {
- return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving search path: %v", err)), nil
- }
- relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
- if err != nil || strings.HasPrefix(relPath, "..") {
- // Directory is outside working directory, request permission
- sessionID := GetSessionFromContext(ctx)
- if sessionID == "" {
- return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
- }
- granted := permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: absSearchPath,
- ToolCallID: call.ID,
- ToolName: LSToolName,
- Action: "list",
- Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
- Params: LSPermissionsParams(params),
- },
- )
- if !granted {
- return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
- }
- }
- output, metadata, err := ListDirectoryTree(searchPath, params, lsConfig)
- if err != nil {
- return fantasy.NewTextErrorResponse(err.Error()), err
- }
- return fantasy.WithResponseMetadata(
- fantasy.NewTextResponse(output),
- metadata,
- ), nil
- })
- }
- func ListDirectoryTree(searchPath string, params LSParams, lsConfig config.ToolLs) (string, LSResponseMetadata, error) {
- if _, err := os.Stat(searchPath); os.IsNotExist(err) {
- return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
- }
- depth, limit := lsConfig.Limits()
- maxFiles := cmp.Or(limit, maxLSFiles)
- files, truncated, err := fsext.ListDirectory(
- searchPath,
- params.Ignore,
- cmp.Or(params.Depth, depth),
- maxFiles,
- )
- if err != nil {
- return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
- }
- metadata := LSResponseMetadata{
- NumberOfFiles: len(files),
- Truncated: truncated,
- }
- tree := createFileTree(files, searchPath)
- var output string
- if truncated {
- 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)
- }
- if depth > 0 {
- 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))
- }
- return output + "\n" + printTree(tree, searchPath), metadata, nil
- }
- func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
- root := []*TreeNode{}
- pathMap := make(map[string]*TreeNode)
- for _, path := range sortedPaths {
- relativePath := strings.TrimPrefix(path, rootPath)
- parts := strings.Split(relativePath, string(filepath.Separator))
- currentPath := ""
- var parentPath string
- var cleanParts []string
- for _, part := range parts {
- if part != "" {
- cleanParts = append(cleanParts, part)
- }
- }
- parts = cleanParts
- if len(parts) == 0 {
- continue
- }
- for i, part := range parts {
- if currentPath == "" {
- currentPath = part
- } else {
- currentPath = filepath.Join(currentPath, part)
- }
- if _, exists := pathMap[currentPath]; exists {
- parentPath = currentPath
- continue
- }
- isLastPart := i == len(parts)-1
- isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
- nodeType := "file"
- if isDir {
- nodeType = "directory"
- }
- newNode := &TreeNode{
- Name: part,
- Path: currentPath,
- Type: nodeType,
- Children: []*TreeNode{},
- }
- pathMap[currentPath] = newNode
- if i > 0 && parentPath != "" {
- if parent, ok := pathMap[parentPath]; ok {
- parent.Children = append(parent.Children, newNode)
- }
- } else {
- root = append(root, newNode)
- }
- parentPath = currentPath
- }
- }
- return root
- }
- func printTree(tree []*TreeNode, rootPath string) string {
- var result strings.Builder
- result.WriteString("- ")
- result.WriteString(filepath.ToSlash(rootPath))
- if rootPath[len(rootPath)-1] != '/' {
- result.WriteByte('/')
- }
- result.WriteByte('\n')
- for _, node := range tree {
- printNode(&result, node, 1)
- }
- return result.String()
- }
- func printNode(builder *strings.Builder, node *TreeNode, level int) {
- indent := strings.Repeat(" ", level)
- nodeName := node.Name
- if node.Type == "directory" {
- nodeName = nodeName + "/"
- }
- fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
- if node.Type == "directory" && len(node.Children) > 0 {
- for _, child := range node.Children {
- printNode(builder, child, level+1)
- }
- }
- }
|