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 Path string `json:"path"`
40 NumberOfFiles int `json:"number_of_files"`
41 Truncated bool `json:"truncated"`
42}
43
44const (
45 LSToolName = "ls"
46 maxLSFiles = 1000
47)
48
49//go:embed ls.md
50var lsDescription []byte
51
52func NewLsTool(permissions permission.Service, workingDir string, lsConfig config.ToolLs) fantasy.AgentTool {
53 return fantasy.NewAgentTool(
54 LSToolName,
55 string(lsDescription),
56 func(ctx context.Context, params LSParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
57 searchPath, err := fsext.Expand(cmp.Or(params.Path, workingDir))
58 if err != nil {
59 return fantasy.NewTextErrorResponse(fmt.Sprintf("error expanding path: %v", err)), nil
60 }
61
62 searchPath = filepathext.SmartJoin(workingDir, searchPath)
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.NewTextErrorResponse(err.Error()), nil
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 := cmp.Or(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 Path: searchPath,
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}