ls.go

  1package tools
  2
  3import (
  4	"cmp"
  5	"context"
  6	_ "embed"
  7	"fmt"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11
 12	"charm.land/fantasy"
 13	"github.com/charmbracelet/crush/internal/config"
 14	"github.com/charmbracelet/crush/internal/fsext"
 15	"github.com/charmbracelet/crush/internal/permission"
 16)
 17
 18type LSParams struct {
 19	Path   string   `json:"path,omitempty" description:"The path to the directory to list (defaults to current working directory)"`
 20	Ignore []string `json:"ignore,omitempty" description:"List of glob patterns to ignore"`
 21	Depth  int      `json:"depth,omitempty" description:"The maximum depth to traverse"`
 22}
 23
 24type LSPermissionsParams struct {
 25	Path   string   `json:"path"`
 26	Ignore []string `json:"ignore"`
 27	Depth  int      `json:"depth"`
 28}
 29
 30type TreeNode struct {
 31	Name     string      `json:"name"`
 32	Path     string      `json:"path"`
 33	Type     string      `json:"type"` // "file" or "directory"
 34	Children []*TreeNode `json:"children,omitempty"`
 35}
 36
 37type LSResponseMetadata struct {
 38	NumberOfFiles int  `json:"number_of_files"`
 39	Truncated     bool `json:"truncated"`
 40}
 41
 42const (
 43	LSToolName = "ls"
 44	maxLSFiles = 1000
 45)
 46
 47//go:embed ls.md
 48var lsDescription []byte
 49
 50func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool {
 51	return fantasy.NewAgentTool(
 52		LSToolName,
 53		string(lsDescription),
 54		func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
 55			searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir))
 56			if err != nil {
 57				return fantasy.NewTextErrorResponse(fmt.Sprintf("error expanding path: %v", err)), nil
 58			}
 59
 60			if !filepath.IsAbs(searchPath) {
 61				searchPath = filepath.Join(workingDir, searchPath)
 62			}
 63
 64			// Check if directory is outside working directory and request permission if needed
 65			absWorkingDir, err := filepath.Abs(workingDir)
 66			if err != nil {
 67				return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving working directory: %v", err)), nil
 68			}
 69
 70			absSearchPath, err := filepath.Abs(searchPath)
 71			if err != nil {
 72				return fantasy.NewTextErrorResponse(fmt.Sprintf("error resolving search path: %v", err)), nil
 73			}
 74
 75			relPath, err := filepath.Rel(absWorkingDir, absSearchPath)
 76			if err != nil || strings.HasPrefix(relPath, "..") {
 77				// Directory is outside working directory, request permission
 78				sessionID := GetSessionFromContext(ctx)
 79				if sessionID == "" {
 80					return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
 81				}
 82
 83				granted := permissions.Request(
 84					permission.CreatePermissionRequest{
 85						SessionID:   sessionID,
 86						Path:        absSearchPath,
 87						ToolCallID:  call.ID,
 88						ToolName:    LSToolName,
 89						Action:      "list",
 90						Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
 91						Params:      LSPermissionsParams(params),
 92					},
 93				)
 94
 95				if !granted {
 96					return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 97				}
 98			}
 99
100			output, metadata, err := ListDirectoryTree(searchPath, params, lsConfig)
101			if err != nil {
102				return fantasy.ToolResponse{}, err
103			}
104
105			return fantasy.WithResponseMetadata(
106				fantasy.NewTextResponse(output),
107				metadata,
108			), nil
109		})
110}
111
112func ListDirectoryTree(searchPath string, params LSParams, lsConfig config.ToolLs) (string, LSResponseMetadata, error) {
113	if _, err := os.Stat(searchPath); os.IsNotExist(err) {
114		return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
115	}
116
117	depth, limit := lsConfig.Limits()
118	maxFiles := min(limit, maxLSFiles)
119	files, truncated, err := fsext.ListDirectory(
120		searchPath,
121		params.Ignore,
122		cmp.Or(params.Depth, depth),
123		maxFiles,
124	)
125	if err != nil {
126		return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
127	}
128
129	metadata := LSResponseMetadata{
130		NumberOfFiles: len(files),
131		Truncated:     truncated,
132	}
133	tree := createFileTree(files, searchPath)
134
135	var output string
136	if truncated {
137		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 %[1]d files and directories are included below.\n", maxFiles)
138	}
139	if depth > 0 {
140		output = fmt.Sprintf("The directory tree is shown up to a depth of %d. Use a higher depth and a specific path to see more levels.\n", cmp.Or(params.Depth, depth))
141	}
142	return output + "\n" + printTree(tree, searchPath), metadata, nil
143}
144
145func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
146	root := []*TreeNode{}
147	pathMap := make(map[string]*TreeNode)
148
149	for _, path := range sortedPaths {
150		relativePath := strings.TrimPrefix(path, rootPath)
151		parts := strings.Split(relativePath, string(filepath.Separator))
152		currentPath := ""
153		var parentPath string
154
155		var cleanParts []string
156		for _, part := range parts {
157			if part != "" {
158				cleanParts = append(cleanParts, part)
159			}
160		}
161		parts = cleanParts
162
163		if len(parts) == 0 {
164			continue
165		}
166
167		for i, part := range parts {
168			if currentPath == "" {
169				currentPath = part
170			} else {
171				currentPath = filepath.Join(currentPath, part)
172			}
173
174			if _, exists := pathMap[currentPath]; exists {
175				parentPath = currentPath
176				continue
177			}
178
179			isLastPart := i == len(parts)-1
180			isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
181			nodeType := "file"
182			if isDir {
183				nodeType = "directory"
184			}
185			newNode := &TreeNode{
186				Name:     part,
187				Path:     currentPath,
188				Type:     nodeType,
189				Children: []*TreeNode{},
190			}
191
192			pathMap[currentPath] = newNode
193
194			if i > 0 && parentPath != "" {
195				if parent, ok := pathMap[parentPath]; ok {
196					parent.Children = append(parent.Children, newNode)
197				}
198			} else {
199				root = append(root, newNode)
200			}
201
202			parentPath = currentPath
203		}
204	}
205
206	return root
207}
208
209func printTree(tree []*TreeNode, rootPath string) string {
210	var result strings.Builder
211
212	result.WriteString("- ")
213	result.WriteString(rootPath)
214	if rootPath[len(rootPath)-1] != '/' {
215		result.WriteByte(filepath.Separator)
216	}
217	result.WriteByte('\n')
218
219	for _, node := range tree {
220		printNode(&result, node, 1)
221	}
222
223	return result.String()
224}
225
226func printNode(builder *strings.Builder, node *TreeNode, level int) {
227	indent := strings.Repeat("  ", level)
228
229	nodeName := node.Name
230	if node.Type == "directory" {
231		nodeName = nodeName + string(filepath.Separator)
232	}
233
234	fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
235
236	if node.Type == "directory" && len(node.Children) > 0 {
237		for _, child := range node.Children {
238			printNode(builder, child, level+1)
239		}
240	}
241}