Skip to content

Commit 8e02566

Browse files
rekram1-nodekeren-mxr
authored andcommitted
feat: adjust read tool so that it can handle dirs too (anomalyco#13090)
1 parent 8a75d74 commit 8e02566

File tree

5 files changed

+115
-22
lines changed

5 files changed

+115
-22
lines changed

.opencode/skill/bun-file-io/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ description: Use this when you are working on file operations like reading, writ
3232
- Decode tool stderr with `Bun.readableStreamToText`.
3333
- For large writes, use `Bun.write(Bun.file(path), text)`.
3434

35+
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
36+
Use Filesystem.exists(...) instead if path can be file or directory
37+
3538
## Quick checklist
3639

3740
- Use Bun APIs first.

packages/opencode/src/tool/edit.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ Performs exact string replacements in files.
22

33
Usage:
44
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
5-
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
5+
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
66
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
77
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
88
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
9-
- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
9+
- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
1010
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.

packages/opencode/src/tool/read.ts

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ const MAX_BYTES = 50 * 1024
1717
export const ReadTool = Tool.define("read", {
1818
description: DESCRIPTION,
1919
parameters: z.object({
20-
filePath: z.string().describe("The path to the file to read"),
21-
offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(),
22-
limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(),
20+
filePath: z.string().describe("The absolute path to the file or directory to read"),
21+
offset: z.coerce.number().describe("The 0-based line offset to start reading from").optional(),
22+
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
2323
}),
2424
async execute(params, ctx) {
2525
let filepath = params.filePath
@@ -28,8 +28,12 @@ export const ReadTool = Tool.define("read", {
2828
}
2929
const title = path.relative(Instance.worktree, filepath)
3030

31+
const file = Bun.file(filepath)
32+
const stat = await file.stat().catch(() => undefined)
33+
3134
await assertExternalDirectory(ctx, filepath, {
3235
bypass: Boolean(ctx.extra?.["bypassCwdCheck"]),
36+
kind: stat?.isDirectory() ? "directory" : "file",
3337
})
3438

3539
await ctx.ask({
@@ -39,8 +43,7 @@ export const ReadTool = Tool.define("read", {
3943
metadata: {},
4044
})
4145

42-
const file = Bun.file(filepath)
43-
if (!(await file.exists())) {
46+
if (!stat) {
4447
const dir = path.dirname(filepath)
4548
const base = path.basename(filepath)
4649

@@ -60,6 +63,47 @@ export const ReadTool = Tool.define("read", {
6063
throw new Error(`File not found: ${filepath}`)
6164
}
6265

66+
if (stat.isDirectory()) {
67+
const dirents = await fs.promises.readdir(filepath, { withFileTypes: true })
68+
const entries = await Promise.all(
69+
dirents.map(async (dirent) => {
70+
if (dirent.isDirectory()) return dirent.name + "/"
71+
if (dirent.isSymbolicLink()) {
72+
const target = await fs.promises.stat(path.join(filepath, dirent.name)).catch(() => undefined)
73+
if (target?.isDirectory()) return dirent.name + "/"
74+
}
75+
return dirent.name
76+
}),
77+
)
78+
entries.sort((a, b) => a.localeCompare(b))
79+
80+
const limit = params.limit ?? DEFAULT_READ_LIMIT
81+
const offset = params.offset || 0
82+
const sliced = entries.slice(offset, offset + limit)
83+
const truncated = offset + sliced.length < entries.length
84+
85+
const output = [
86+
`<path>${filepath}</path>`,
87+
`<type>directory</type>`,
88+
`<entries>`,
89+
sliced.join("\n"),
90+
truncated
91+
? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
92+
: `\n(${entries.length} entries)`,
93+
`</entries>`,
94+
].join("\n")
95+
96+
return {
97+
title,
98+
output,
99+
metadata: {
100+
preview: sliced.slice(0, 20).join("\n"),
101+
truncated,
102+
loaded: [] as string[],
103+
},
104+
}
105+
}
106+
63107
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
64108

65109
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
@@ -75,7 +119,7 @@ export const ReadTool = Tool.define("read", {
75119
metadata: {
76120
preview: msg,
77121
truncated: false,
78-
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
122+
loaded: instructions.map((i) => i.filepath),
79123
},
80124
attachments: [
81125
{
@@ -112,11 +156,11 @@ export const ReadTool = Tool.define("read", {
112156
}
113157

114158
const content = raw.map((line, index) => {
115-
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
159+
return `${index + offset + 1}: ${line}`
116160
})
117161
const preview = raw.slice(0, 20).join("\n")
118162

119-
let output = "<file>\n"
163+
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
120164
output += content.join("\n")
121165

122166
const totalLines = lines.length
@@ -131,7 +175,7 @@ export const ReadTool = Tool.define("read", {
131175
} else {
132176
output += `\n\n(End of file - total ${totalLines} lines)`
133177
}
134-
output += "\n</file>"
178+
output += "\n</content>"
135179

136180
// just warms the lsp client
137181
LSP.touchFile(filepath, false)
@@ -147,7 +191,7 @@ export const ReadTool = Tool.define("read", {
147191
metadata: {
148192
preview,
149193
truncated,
150-
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
194+
loaded: instructions.map((i) => i.filepath),
151195
},
152196
}
153197
},
Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
Reads a file from the local filesystem. You can access any file directly by using this tool.
2-
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
1+
Read a file or directory from the local filesystem. If the path does not exist, an error is returned.
32

43
Usage:
5-
- The filePath parameter must be an absolute path, not a relative path
6-
- By default, it reads up to 2000 lines starting from the beginning of the file
7-
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
8-
- Any lines longer than 2000 characters will be truncated
9-
- Results are returned using cat -n format, with line numbers starting at 1
10-
- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
11-
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
12-
- You can read image files using this tool.
4+
- The filePath parameter should be an absolute path.
5+
- By default, this tool returns up to 2000 lines from the start of the file.
6+
- To read later sections, call this tool again with a larger offset.
7+
- Use the grep tool to find specific content in large files or files with long lines.
8+
- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
9+
- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
10+
- Any line longer than 2000 characters is truncated.
11+
- Call this tool in parallel when you know there are multiple files you want to read.
12+
- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.
13+
- This tool can read image files and PDFs and return them as file attachments.

packages/opencode/test/tool/read.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,32 @@ describe("tool.read external_directory permission", () => {
7878
})
7979
})
8080

81+
test("asks for directory-scoped external_directory permission when reading external directory", async () => {
82+
await using outerTmp = await tmpdir({
83+
init: async (dir) => {
84+
await Bun.write(path.join(dir, "external", "a.txt"), "a")
85+
},
86+
})
87+
await using tmp = await tmpdir({ git: true })
88+
await Instance.provide({
89+
directory: tmp.path,
90+
fn: async () => {
91+
const read = await ReadTool.init()
92+
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
93+
const testCtx = {
94+
...ctx,
95+
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
96+
requests.push(req)
97+
},
98+
}
99+
await read.execute({ filePath: path.join(outerTmp.path, "external") }, testCtx)
100+
const extDirReq = requests.find((r) => r.permission === "external_directory")
101+
expect(extDirReq).toBeDefined()
102+
expect(extDirReq!.patterns).toContain(path.join(outerTmp.path, "external", "*"))
103+
},
104+
})
105+
})
106+
81107
test("asks for external_directory permission when reading relative path outside project", async () => {
82108
await using tmp = await tmpdir({ git: true })
83109
await Instance.provide({
@@ -249,6 +275,25 @@ describe("tool.read truncation", () => {
249275
})
250276
})
251277

278+
test("does not mark final directory page as truncated", async () => {
279+
await using tmp = await tmpdir({
280+
init: async (dir) => {
281+
await Promise.all(
282+
Array.from({ length: 10 }, (_, i) => Bun.write(path.join(dir, "dir", `file-${i}.txt`), `line${i}`)),
283+
)
284+
},
285+
})
286+
await Instance.provide({
287+
directory: tmp.path,
288+
fn: async () => {
289+
const read = await ReadTool.init()
290+
const result = await read.execute({ filePath: path.join(tmp.path, "dir"), offset: 5, limit: 5 }, ctx)
291+
expect(result.metadata.truncated).toBe(false)
292+
expect(result.output).not.toContain("Showing 5 of 10 entries")
293+
},
294+
})
295+
})
296+
252297
test("truncates long lines", async () => {
253298
await using tmp = await tmpdir({
254299
init: async (dir) => {

0 commit comments

Comments
 (0)