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 := permissions.Request(
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 )
93
94 if !granted {
95 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
96 }
97 }
98
99 output, metadata, err := ListDirectoryTree(searchPath, params, lsConfig)
100 if err != nil {
101 return fantasy.NewTextErrorResponse(err.Error()), err
102 }
103
104 return fantasy.WithResponseMetadata(
105 fantasy.NewTextResponse(output),
106 metadata,
107 ), nil
108 })
109}
110
111func ListDirectoryTree(searchPath string, params LSParams, lsConfig config.ToolLs) (string, LSResponseMetadata, error) {
112 if _, err := os.Stat(searchPath); os.IsNotExist(err) {
113 return "", LSResponseMetadata{}, fmt.Errorf("path does not exist: %s", searchPath)
114 }
115
116 depth, limit := lsConfig.Limits()
117 maxFiles := cmp.Or(limit, maxLSFiles)
118 files, truncated, err := fsext.ListDirectory(
119 searchPath,
120 params.Ignore,
121 cmp.Or(params.Depth, depth),
122 maxFiles,
123 )
124 if err != nil {
125 return "", LSResponseMetadata{}, fmt.Errorf("error listing directory: %w", err)
126 }
127
128 metadata := LSResponseMetadata{
129 NumberOfFiles: len(files),
130 Truncated: truncated,
131 }
132 tree := createFileTree(files, searchPath)
133
134 var output string
135 if truncated {
136 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)
137 }
138 if depth > 0 {
139 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))
140 }
141 return output + "\n" + printTree(tree, searchPath), metadata, nil
142}
143
144func createFileTree(sortedPaths []string, rootPath string) []*TreeNode {
145 root := []*TreeNode{}
146 pathMap := make(map[string]*TreeNode)
147
148 for _, path := range sortedPaths {
149 relativePath := strings.TrimPrefix(path, rootPath)
150 parts := strings.Split(relativePath, string(filepath.Separator))
151 currentPath := ""
152 var parentPath string
153
154 var cleanParts []string
155 for _, part := range parts {
156 if part != "" {
157 cleanParts = append(cleanParts, part)
158 }
159 }
160 parts = cleanParts
161
162 if len(parts) == 0 {
163 continue
164 }
165
166 for i, part := range parts {
167 if currentPath == "" {
168 currentPath = part
169 } else {
170 currentPath = filepath.Join(currentPath, part)
171 }
172
173 if _, exists := pathMap[currentPath]; exists {
174 parentPath = currentPath
175 continue
176 }
177
178 isLastPart := i == len(parts)-1
179 isDir := !isLastPart || strings.HasSuffix(relativePath, string(filepath.Separator))
180 nodeType := "file"
181 if isDir {
182 nodeType = "directory"
183 }
184 newNode := &TreeNode{
185 Name: part,
186 Path: currentPath,
187 Type: nodeType,
188 Children: []*TreeNode{},
189 }
190
191 pathMap[currentPath] = newNode
192
193 if i > 0 && parentPath != "" {
194 if parent, ok := pathMap[parentPath]; ok {
195 parent.Children = append(parent.Children, newNode)
196 }
197 } else {
198 root = append(root, newNode)
199 }
200
201 parentPath = currentPath
202 }
203 }
204
205 return root
206}
207
208func printTree(tree []*TreeNode, rootPath string) string {
209 var result strings.Builder
210
211 result.WriteString("- ")
212 result.WriteString(filepath.ToSlash(rootPath))
213 if rootPath[len(rootPath)-1] != '/' {
214 result.WriteByte('/')
215 }
216 result.WriteByte('\n')
217
218 for _, node := range tree {
219 printNode(&result, node, 1)
220 }
221
222 return result.String()
223}
224
225func printNode(builder *strings.Builder, node *TreeNode, level int) {
226 indent := strings.Repeat(" ", level)
227
228 nodeName := node.Name
229 if node.Type == "directory" {
230 nodeName = nodeName + "/"
231 }
232
233 fmt.Fprintf(builder, "%s- %s\n", indent, nodeName)
234
235 if node.Type == "directory" && len(node.Children) > 0 {
236 for _, child := range node.Children {
237 printNode(builder, child, level+1)
238 }
239 }
240}