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}