Skip to content

Commit da7bcf4

Browse files
Copilotsawka
andcommitted
Add read_dir tool for reading directories
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
1 parent d92a3eb commit da7bcf4

3 files changed

Lines changed: 401 additions & 0 deletions

File tree

pkg/aiusechat/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func GenerateTabStateAndTools(ctx context.Context, tabid string, widgetAccess bo
127127
if widgetAccess {
128128
tools = append(tools, GetCaptureScreenshotToolDefinition(tabid))
129129
tools = append(tools, GetReadTextFileToolDefinition())
130+
tools = append(tools, GetReadDirToolDefinition())
130131
viewTypes := make(map[string]bool)
131132
for _, block := range blocks {
132133
if block.Meta == nil {

pkg/aiusechat/tools_readdir.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package aiusechat
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"sort"
11+
"strings"
12+
13+
"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
14+
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
15+
"github.com/wavetermdev/waveterm/pkg/wavebase"
16+
)
17+
18+
const ReadDirDefaultMaxEntries = 1000
19+
20+
type readDirParams struct {
21+
Path string `json:"path"`
22+
MaxEntries *int `json:"max_entries"`
23+
}
24+
25+
func parseReadDirInput(input any) (*readDirParams, error) {
26+
result := &readDirParams{}
27+
28+
if input == nil {
29+
return nil, fmt.Errorf("input is required")
30+
}
31+
32+
if err := utilfn.ReUnmarshal(result, input); err != nil {
33+
return nil, fmt.Errorf("invalid input format: %w", err)
34+
}
35+
36+
if result.Path == "" {
37+
return nil, fmt.Errorf("missing path parameter")
38+
}
39+
40+
if result.MaxEntries == nil {
41+
maxEntries := ReadDirDefaultMaxEntries
42+
result.MaxEntries = &maxEntries
43+
}
44+
45+
if *result.MaxEntries < 1 {
46+
return nil, fmt.Errorf("max_entries must be at least 1, got %d", *result.MaxEntries)
47+
}
48+
49+
return result, nil
50+
}
51+
52+
func readDirCallback(input any) (any, error) {
53+
params, err := parseReadDirInput(input)
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
expandedPath, err := wavebase.ExpandHomeDir(params.Path)
59+
if err != nil {
60+
return nil, fmt.Errorf("failed to expand path: %w", err)
61+
}
62+
63+
fileInfo, err := os.Stat(expandedPath)
64+
if err != nil {
65+
return nil, fmt.Errorf("failed to stat path: %w", err)
66+
}
67+
68+
if !fileInfo.IsDir() {
69+
return nil, fmt.Errorf("path is not a directory, cannot be read with the read_dir tool. use the read_text_file tool to read files")
70+
}
71+
72+
entries, err := os.ReadDir(expandedPath)
73+
if err != nil {
74+
return nil, fmt.Errorf("failed to read directory: %w", err)
75+
}
76+
77+
maxEntries := *params.MaxEntries
78+
var truncated bool
79+
if len(entries) > maxEntries {
80+
entries = entries[:maxEntries]
81+
truncated = true
82+
}
83+
84+
// Sort entries: directories first, then files, alphabetically within each group
85+
sort.Slice(entries, func(i, j int) bool {
86+
if entries[i].IsDir() != entries[j].IsDir() {
87+
return entries[i].IsDir()
88+
}
89+
return entries[i].Name() < entries[j].Name()
90+
})
91+
92+
var entryList []map[string]any
93+
for _, entry := range entries {
94+
info, err := entry.Info()
95+
if err != nil {
96+
// Skip entries we can't stat
97+
continue
98+
}
99+
100+
entryData := map[string]any{
101+
"name": entry.Name(),
102+
"is_dir": entry.IsDir(),
103+
"mode": info.Mode().String(),
104+
"modified": utilfn.FormatRelativeTime(info.ModTime()),
105+
}
106+
107+
if !entry.IsDir() {
108+
entryData["size"] = info.Size()
109+
}
110+
111+
entryList = append(entryList, entryData)
112+
}
113+
114+
// Create a formatted directory listing
115+
var listing strings.Builder
116+
for _, entry := range entryList {
117+
name := entry["name"].(string)
118+
isDir := entry["is_dir"].(bool)
119+
mode := entry["mode"].(string)
120+
modified := entry["modified"].(string)
121+
122+
if isDir {
123+
listing.WriteString(fmt.Sprintf("[DIR] %-40s %s %s\n", name, mode, modified))
124+
} else {
125+
size := entry["size"].(int64)
126+
listing.WriteString(fmt.Sprintf("[FILE] %-40s %10d %s %s\n", name, size, mode, modified))
127+
}
128+
}
129+
130+
result := map[string]any{
131+
"path": params.Path,
132+
"absolute_path": expandedPath,
133+
"entry_count": len(entryList),
134+
"total_entries": len(entries),
135+
"entries": entryList,
136+
"listing": strings.TrimSuffix(listing.String(), "\n"),
137+
}
138+
139+
if truncated {
140+
result["truncated"] = true
141+
result["truncated_message"] = fmt.Sprintf("Directory listing truncated to %d entries (out of %d+ total). Increase max_entries to see more.", maxEntries, maxEntries)
142+
}
143+
144+
// Get absolute path of parent directory for context
145+
parentDir := filepath.Dir(expandedPath)
146+
if parentDir != expandedPath {
147+
result["parent_dir"] = parentDir
148+
}
149+
150+
return result, nil
151+
}
152+
153+
func GetReadDirToolDefinition() uctypes.ToolDefinition {
154+
return uctypes.ToolDefinition{
155+
Name: "read_dir",
156+
DisplayName: "Read Directory",
157+
Description: "Read a directory from the filesystem and list its contents. Returns information about files and subdirectories including names, types, sizes, permissions, and modification times. Requires user approval.",
158+
ToolLogName: "gen:readdir",
159+
Strict: false,
160+
InputSchema: map[string]any{
161+
"type": "object",
162+
"properties": map[string]any{
163+
"path": map[string]any{
164+
"type": "string",
165+
"description": "Path to the directory to read",
166+
},
167+
"max_entries": map[string]any{
168+
"type": "integer",
169+
"minimum": 1,
170+
"default": 1000,
171+
"description": "Maximum number of entries to return. Defaults to 1000.",
172+
},
173+
},
174+
"required": []string{"path"},
175+
"additionalProperties": false,
176+
},
177+
ToolInputDesc: func(input any) string {
178+
parsed, err := parseReadDirInput(input)
179+
if err != nil {
180+
return fmt.Sprintf("error parsing input: %v", err)
181+
}
182+
return fmt.Sprintf("reading directory %q", parsed.Path)
183+
},
184+
ToolAnyCallback: readDirCallback,
185+
ToolApproval: func(input any) string {
186+
return uctypes.ApprovalNeedsApproval
187+
},
188+
}
189+
}

0 commit comments

Comments
 (0)