ls.go

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