ls.go

  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}