diff --git a/.opencode/skill/bun-file-io/SKILL.md b/.opencode/skill/bun-file-io/SKILL.md
index ea39507d26..f78de33094 100644
--- a/.opencode/skill/bun-file-io/SKILL.md
+++ b/.opencode/skill/bun-file-io/SKILL.md
@@ -32,6 +32,9 @@ description: Use this when you are working on file operations like reading, writ
- Decode tool stderr with `Bun.readableStreamToText`.
- For large writes, use `Bun.write(Bun.file(path), text)`.
+NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
+Use Filesystem.exists(...) instead if path can be file or directory
+
## Quick checklist
- Use Bun APIs first.
diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt
index 863efb8409..baad14bf18 100644
--- a/packages/opencode/src/tool/edit.txt
+++ b/packages/opencode/src/tool/edit.txt
@@ -2,7 +2,7 @@ Performs exact string replacements in files.
Usage:
- 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.
-- 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.
+- 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.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index f230cdf44c..cc2f5eb58d 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -17,9 +17,9 @@ const MAX_BYTES = 50 * 1024
export const ReadTool = Tool.define("read", {
description: DESCRIPTION,
parameters: z.object({
- filePath: z.string().describe("The path to the file to read"),
- offset: z.coerce.number().describe("The line number to start reading from (0-based)").optional(),
- limit: z.coerce.number().describe("The number of lines to read (defaults to 2000)").optional(),
+ filePath: z.string().describe("The absolute path to the file to read (directories are not yet supported)"),
+ offset: z.coerce.number().describe("The 0-based line offset to start reading from").optional(),
+ limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
}),
async execute(params, ctx) {
let filepath = params.filePath
@@ -40,7 +40,9 @@ export const ReadTool = Tool.define("read", {
})
const file = Bun.file(filepath)
- if (!(await file.exists())) {
+ const stat = await file.stat().catch(() => undefined)
+
+ if (!stat) {
const dir = path.dirname(filepath)
const base = path.basename(filepath)
@@ -60,6 +62,45 @@ export const ReadTool = Tool.define("read", {
throw new Error(`File not found: ${filepath}`)
}
+ if (stat.isDirectory()) {
+ const dirents = await fs.promises.readdir(filepath, { withFileTypes: true })
+ const entries = await Promise.all(
+ dirents.map(async (dirent) => {
+ if (dirent.isDirectory()) return dirent.name + "/"
+ if (dirent.isSymbolicLink()) {
+ const target = await fs.promises.stat(path.join(filepath, dirent.name)).catch(() => undefined)
+ if (target?.isDirectory()) return dirent.name + "/"
+ }
+ return dirent.name
+ }),
+ )
+ entries.sort((a, b) => a.localeCompare(b))
+
+ const limit = params.limit ?? DEFAULT_READ_LIMIT
+ const offset = params.offset || 0
+ const sliced = entries.slice(offset, offset + limit)
+ const truncated = sliced.length < entries.length
+
+ const output = [
+ `${filepath}`,
+ `directory`,
+ ``,
+ sliced.join("\n"),
+ truncated
+ ? `\n(Showing ${sliced.length} of ${entries.length} entries. Use 'offset' parameter to read beyond entry ${offset + sliced.length})`
+ : `\n(${entries.length} entries)`,
+ ``,
+ ].join("\n")
+
+ return {
+ title,
+ output,
+ metadata: {
+ truncated,
+ },
+ }
+ }
+
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
@@ -112,11 +153,11 @@ export const ReadTool = Tool.define("read", {
}
const content = raw.map((line, index) => {
- return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
+ return `${index + offset + 1}: ${line}`
})
const preview = raw.slice(0, 20).join("\n")
- let output = "\n"
+ let output = [`${filepath}`, `file`, ""].join("\n")
output += content.join("\n")
const totalLines = lines.length
@@ -131,7 +172,7 @@ export const ReadTool = Tool.define("read", {
} else {
output += `\n\n(End of file - total ${totalLines} lines)`
}
- output += "\n"
+ output += "\n"
// just warms the lsp client
LSP.touchFile(filepath, false)
diff --git a/packages/opencode/src/tool/read.txt b/packages/opencode/src/tool/read.txt
index b5bffee263..dfb3ecf1cc 100644
--- a/packages/opencode/src/tool/read.txt
+++ b/packages/opencode/src/tool/read.txt
@@ -1,12 +1,13 @@
-Reads a file from the local filesystem. You can access any file directly by using this tool.
-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.
+Read a file from the local filesystem. If the file does not exist, an error is returned.
Usage:
-- The filePath parameter must be an absolute path, not a relative path
-- By default, it reads up to 2000 lines starting from the beginning of the file
-- 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
-- Any lines longer than 2000 characters will be truncated
-- Results are returned using cat -n format, with line numbers starting at 1
-- 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.
-- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
-- You can read image files using this tool.
+- The filePath parameter should be an absolute path.
+- By default, this tool returns up to 2000 lines from the start of the file.
+- To read later sections, call this tool again with a larger offset.
+- Use the grep tool to find specific content in large files or files with long lines.
+- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
+- Contents are returned with each line prefixed by its line number as `: `. 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.
+- Any line longer than 2000 characters is truncated.
+- Call this tool in parallel when you know there are multiple files you want to read.
+- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.
+- This tool can read image files and PDFs and return them as file attachments.