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