feat(opencode): add filesystem read and list routes

This commit is contained in:
Dax Raad
2026-06-02 01:54:10 -04:00
parent 0136f03fa9
commit 5937e606df
10 changed files with 414 additions and 15 deletions

View File

@@ -3,6 +3,7 @@ export * as LocationFileSystem from "./location-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { Context, Effect, Layer, Schema } from "effect"
import { AppFileSystem } from "./filesystem"
import { Location } from "./location"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
@@ -11,13 +12,22 @@ export const ReadInput = Schema.Struct({
})
export type ReadInput = typeof ReadInput.Type
export class Content extends Schema.Class<Content>("LocationFileSystem.Content")({
type: Schema.Literals(["text", "binary"]),
export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
type: Schema.Literal("text"),
content: Schema.String,
encoding: Schema.Literal("base64").pipe(Schema.optional),
mime: Schema.String.pipe(Schema.optional),
mime: Schema.String,
}) {}
export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
type: Schema.Literal("binary"),
content: Schema.String,
encoding: Schema.Literal("base64"),
mime: Schema.String,
}) {}
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
export type Content = typeof Content.Type
export const ListInput = Schema.Struct({
path: RelativePath.pipe(Schema.optional),
})
@@ -70,7 +80,33 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const location = yield* Location.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
const absolute = path.resolve(location.directory, input ?? ".")
if (!AppFileSystem.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!AppFileSystem.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real }
})
const entry = Effect.fnUntraced(function* (absolute: string) {
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!real) return
if (!AppFileSystem.contains(root, real)) return
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
if (!info) return
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
if (!type) return
return new Entry({
path: RelativePath.make(path.relative(location.directory, absolute)),
uri: pathToFileURL(real).href,
type,
mime: type === "directory" ? "application/x-directory" : AppFileSystem.mimeType(real),
})
})
const entries = [
new Entry({
path: RelativePath.make("README.md"),
@@ -87,11 +123,42 @@ export const locationLayer = Layer.effect(
]
return Service.of({
read: Effect.fn("LocationFileSystem.read")(function* () {
return new Content({ type: "text", content: "# opencode\n", mime: "text/markdown" })
read: Effect.fn("LocationFileSystem.read")(function* (input) {
const file = yield* resolve(input.path)
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
const mime = AppFileSystem.mimeType(file.real)
if (!bytes.includes(0)) {
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
Effect.option,
)
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
}
return new BinaryContent({
type: "binary",
content: Buffer.from(bytes).toString("base64"),
encoding: "base64",
mime,
})
}),
list: Effect.fn("LocationFileSystem.list")(function* () {
return entries
list: Effect.fn("LocationFileSystem.list")(function* (input = {}) {
const directory = yield* resolve(input.path)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(directory.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name)), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
find: Effect.fn("LocationFileSystem.find")(function* (input) {
return entries.filter((entry) => input.type === undefined || entry.type === input.type).slice(0, input.limit)

View File

@@ -0,0 +1,92 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
function provide(directory: string) {
return Effect.provide(
LocationFileSystem.locationLayer.pipe(
Layer.provide(
Layer.mergeAll(
AppFileSystem.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
),
),
),
)
}
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path).pipe(provide(tmp.path))))
}
describe("LocationFileSystem", () => {
it.live("reads text and binary files", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
const service = yield* LocationFileSystem.Service
expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
type: "text",
content: "hello",
mime: "text/plain",
})
expect(yield* service.read({ path: RelativePath.make("data.bin") })).toEqual({
type: "binary",
content: "AAEC",
encoding: "base64",
mime: "application/octet-stream",
})
}),
),
)
it.live("lists direct children with relative paths and resolved URIs", () =>
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
const service = yield* LocationFileSystem.Service
expect(yield* service.list()).toEqual([
{
path: RelativePath.make("src"),
uri: pathToFileURL(path.join(directory, "src")).href,
type: "directory",
mime: "application/x-directory",
},
{
path: RelativePath.make("README.md"),
uri: pathToFileURL(path.join(directory, "README.md")).href,
type: "file",
mime: "text/markdown",
},
])
}),
),
)
it.live("rejects paths outside the location", () =>
withTmp((directory) =>
Effect.gen(function* () {
const service = yield* LocationFileSystem.Service
expect(
Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
).toBe(true)
}),
),
)
})