1package tools
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "github.com/charmbracelet/crush/internal/ai"
11 "github.com/charmbracelet/crush/internal/fsext"
12)
13
14const (
15 MaxLSFiles = 1000
16 LSToolName = "ls"
17)
18
19type LSParams struct {
20 Path string `json:"path" 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}
23
24type LSPermissionsParams struct {
25 Path string `json:"path"`
26 Ignore []string `json:"ignore"`
27}
28
29type TreeNode struct {
30 Name string `json:"name"`
31 Path string `json:"path"`
32 Type string `json:"type"`
33 Children []*TreeNode `json:"children,omitempty"`
34}
35
36type LSResponseMetadata struct {
37 NumberOfFiles int `json:"number_of_files"`
38 Truncated bool `json:"truncated"`
39}
40
41func NewLSTool(permissionAsk PermissionAsk, workingDir string) ai.AgentTool {
42 return ai.NewTypedToolFunc(
43 LSToolName,
44 `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
45
46WHEN TO USE THIS TOOL:
47- Use when you need to explore the structure of a directory
48- Helpful for understanding the organization of a project
49- Good first step when getting familiar with a new codebase
50
51HOW TO USE:
52- Provide a path to list (defaults to current working directory)
53- Optionally specify glob patterns to ignore
54- Results are displayed in a tree structure
55
56FEATURES:
57- Displays a hierarchical view of files and directories
58- Automatically skips hidden files/directories (starting with '.')
59- Skips common system directories like __pycache__
60- Can filter out files matching specific patterns
61
62LIMITATIONS:
63- Results are limited to 1000 files
64- Very large directories will be truncated
65- Does not show file sizes or permissions
66- Cannot recursively list all directories in a large project
67
68WINDOWS NOTES:
69- Hidden file detection uses Unix convention (files starting with '.')
70- Windows-specific hidden files (with hidden attribute) are not automatically skipped
71- Common Windows directories like System32, Program Files are not in default ignore list
72- Path separators are handled automatically (both / and \ work)
73
74TIPS:
75- Use Glob tool for finding files by name patterns instead of browsing
76- Use Grep tool for searching file contents
77- Combine with other tools for more effective exploration`,
78 func(ctx context.Context, params LSParams, call ai.ToolCall) (ai.ToolResponse, error) {
79 searchPath := params.Path
80 if searchPath == "" {
81 searchPath = workingDir
82 }
83
84 var err error
85 searchPath, err = fsext.Expand(searchPath)
86 if err != nil {
87 return ai.ToolResponse{}, fmt.Errorf("error expanding path: %w", err)
88 }
89
90 if !filepath.IsAbs(searchPath) {
91 searchPath = filepath.Join(workingDir, searchPath)
92 }
93
94 // Check if directory is outside working directory and request permission if needed
95 absWorkingDir, err := filepath.Abs(workingDir)
96 if err != nil {
97 return ai.ToolResponse{}, fmt.Errorf("error resolving working directory: %w", err)
98 }
99
100 absSearchPath, err := filepath.Abs(searchPath)
101 if err != nil {
102 return ai.ToolResponse{}, fmt.Errorf("error resolving search path: %w", err)
103 }
104
105 relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
106 if err != nil || strings.HasPrefix(relPath, "..") {
107 granted := permissionAsk(Permission{
108 ToolCallID: call.ID,
109 ToolName: LSToolName,
110 Path: absSearchPath,
111 Action: "list",
112 Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
113 Params: LSPermissionsParams(params),
114 })
115
116 if !granted {
117 return ai.ToolResponse{}, ErrorPermissionDenied
118 }
119 }
120 output, err := ListDirectoryTree(searchPath, params.Ignore)
121 if err != nil {
122 return ai.ToolResponse{}, err
123 }
124
125 // Get file count for metadata
126 files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
127 if err != nil {
128 return ai.ToolResponse{}, fmt.Errorf("error listing directory for metadata: %w", err)
129 }
130
131 return ai.WithResponseMetadata(
132 ai.NewTextResponse(output),
133 LSResponseMetadata{
134 NumberOfFiles: len(files),
135 Truncated: truncated,
136 },
137 ), nil
138 },
139 )
140}
141
142func ListDirectoryTree(searchPath string, ignore []string) (string, error) {
143 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
144 return "", fmt.Errorf("path does not exist: %s", searchPath)
145 }
146
147 files, truncated, err := fsext.ListDirectory(searchPath, ignore, MaxLSFiles)
148 if err != nil {
149 return "", fmt.Errorf("error listing directory: %w", err)
150 }
151
152 tree := createFileTree(files, searchPath)
153 output := printTree(tree, searchPath)
154
155 if truncated {
156 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 %d files and directories are included below:\n\n%s", MaxLSFiles, MaxLSFiles, output)
157 }
158
159 return output, nil
160}
161
162func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
163 root := []*TreeNode{}
164 pathMap := make(map[string]*TreeNode)
165
166 for _, path := range sortedPaths {
167 relativePath := strings.TrimPrefix(path, rootPath)
168 parts := strings.Split(relativePath, string(filepath.Separator))
169 currentPath := ""
170 var parentPath string
171
172 var cleanParts []string
173 for _, part := range parts {
174 if part != "" {
175 cleanParts = append(cleanParts, part)
176 }
177 }
178 parts = cleanParts
179
180 if len(parts) == 0 {
181 continue
182 }
183
184 for i, part := range parts {
185 if currentPath == "" {
186 currentPath = part
187 } else {
188 currentPath = filepath.Join(currentPath, part)
189 }
190
191 if _, exists := pathMap[currentPath]; exists {
192 parentPath = currentPath
193 continue
194 }
195
196 isLastPart := i == len(parts)-1
197 isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
198 nodeType := "file"
199 if isDir {
200 nodeType = "directory"
201 }
202 newNode := &TreeNode{
203 Name: part,
204 Path: currentPath,
205 Type: nodeType,
206 Children: []*TreeNode{},
207 }
208
209 pathMap[currentPath] = newNode
210
211 if i > 0 && parentPath != "" {
212 if parent, ok := pathMap[parentPath]; ok {
213 parent.Children = append(parent.Children, newNode)
214 }
215 } else {
216 root = append(root, newNode)
217 }
218
219 parentPath = currentPath
220 }
221 }
222
223 return root
224}
225
226func printTree(tree []*TreeNode, rootPath string) string {
227 var result strings.Builder
228
229 result.WriteString("- ")
230 result.WriteString(rootPath)
231 if rootPath[len(rootPath)-1] != '/' {
232 result.WriteByte(filepath.Separator)
233 }
234 result.WriteByte('\n')
235
236 for _, node := range tree {
237 printNode(&result, node, 1)
238 }
239
240 return result.String()
241}
242
243func printNode(builder *strings.Builder, node *TreeNode, level int) {
244 indent := strings.Repeat(" ", level)
245
246 nodeName := node.Name
247 if node.Type == "directory" {
248 nodeName = nodeName + string(filepath.Separator)
249 }
250
251 fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
252
253 if node.Type == "directory" && len(node.Children) > 0 {
254 for _, child := range node.Children {
255 printNode(builder, child, level+1)
256 }
257 }
258}