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