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