ls.go

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