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