ls.go

  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)
 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	workingDir string
 33}
 34
 35const (
 36	LSToolName    = "ls"
 37	MaxLSFiles    = 1000
 38	lsDescription = `Directory listing tool that shows files and subdirectories in a tree structure, helping you explore and understand the project organization.
 39
 40WHEN TO USE THIS TOOL:
 41- Use when you need to explore the structure of a directory
 42- Helpful for understanding the organization of a project
 43- Good first step when getting familiar with a new codebase
 44
 45HOW TO USE:
 46- Provide a path to list (defaults to current working directory)
 47- Optionally specify glob patterns to ignore
 48- Results are displayed in a tree structure
 49
 50FEATURES:
 51- Displays a hierarchical view of files and directories
 52- Automatically skips hidden files/directories (starting with '.')
 53- Skips common system directories like __pycache__
 54- Can filter out files matching specific patterns
 55
 56LIMITATIONS:
 57- Results are limited to 1000 files
 58- Very large directories will be truncated
 59- Does not show file sizes or permissions
 60- Cannot recursively list all directories in a large project
 61
 62WINDOWS NOTES:
 63- Hidden file detection uses Unix convention (files starting with '.')
 64- Windows-specific hidden files (with hidden attribute) are not automatically skipped
 65- Common Windows directories like System32, Program Files are not in default ignore list
 66- Path separators are handled automatically (both / and \ work)
 67
 68TIPS:
 69- Use Glob tool for finding files by name patterns instead of browsing
 70- Use Grep tool for searching file contents
 71- Combine with other tools for more effective exploration`
 72)
 73
 74func NewLsTool(workingDir string) BaseTool {
 75	return &lsTool{
 76		workingDir: workingDir,
 77	}
 78}
 79
 80func (l *lsTool) Name() string {
 81	return LSToolName
 82}
 83
 84func (l *lsTool) Info() ToolInfo {
 85	return ToolInfo{
 86		Name:        LSToolName,
 87		Description: lsDescription,
 88		Parameters: map[string]any{
 89			"path": map[string]any{
 90				"type":        "string",
 91				"description": "The path to the directory to list (defaults to current working directory)",
 92			},
 93			"ignore": map[string]any{
 94				"type":        "array",
 95				"description": "List of glob patterns to ignore",
 96				"items": map[string]any{
 97					"type": "string",
 98				},
 99			},
100		},
101		Required: []string{"path"},
102	}
103}
104
105func (l *lsTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
106	var params LSParams
107	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
108		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
109	}
110
111	searchPath := params.Path
112	if searchPath == "" {
113		searchPath = l.workingDir
114	}
115
116	if !filepath.IsAbs(searchPath) {
117		searchPath = filepath.Join(l.workingDir, searchPath)
118	}
119
120	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
121		return NewTextErrorResponse(fmt.Sprintf("path does not exist: %s", searchPath)), nil
122	}
123
124	files, truncated, err := fsext.ListDirectory(searchPath, params.Ignore, MaxLSFiles)
125	if err != nil {
126		return ToolResponse{}, fmt.Errorf("error listing directory: %w", err)
127	}
128
129	tree := createFileTree(files)
130	output := printTree(tree, searchPath)
131
132	if truncated {
133		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)
134	}
135
136	return WithResponseMetadata(
137		NewTextResponse(output),
138		LSResponseMetadata{
139			NumberOfFiles: len(files),
140			Truncated:     truncated,
141		},
142	), nil
143}
144
145func createFileTree(sortedPaths []string) []*TreeNode {
146	root := []*TreeNode{}
147	pathMap := make(map[string]*TreeNode)
148
149	for _, path := range sortedPaths {
150		parts := strings.Split(path, 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(path, 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(fmt.Sprintf("- %s%s\n", rootPath, string(filepath.Separator)))
212
213	for _, node := range tree {
214		printNode(&result, node, 1)
215	}
216
217	return result.String()
218}
219
220func printNode(builder *strings.Builder, node *TreeNode, level int) {
221	indent := strings.Repeat("  ", level)
222
223	nodeName := node.Name
224	if node.Type == "directory" {
225		nodeName = nodeName + string(filepath.Separator)
226	}
227
228	fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
229
230	if node.Type == "directory" && len(node.Children) > 0 {
231		for _, child := range node.Children {
232			printNode(builder, child, level+1)
233		}
234	}
235}