ls.go

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