summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-24 09:12:05 -0400
committerGitHub <[email protected]>2026-04-24 09:12:05 -0400
commit9f7ecd65e5534451f8feeb2ef45e1cefb4fc8484 (patch)
tree2f60058a1bcf6c848216b47ae99e46c058f85dc8
parentf8e939d96fe2f2f3e347fdfd8a8f567ceb6edb01 (diff)
downloadopencode-9f7ecd65e5534451f8feeb2ef45e1cefb4fc8484.tar.gz
opencode-9f7ecd65e5534451f8feeb2ef45e1cefb4fc8484.zip
feat(httpapi): bridge file read endpoints (#24098)
-rw-r--r--packages/opencode/specs/effect/http-api.md7
-rw-r--r--packages/opencode/src/file/index.ts110
-rw-r--r--packages/opencode/src/server/routes/instance/file.ts6
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/file.ts84
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts3
-rw-r--r--packages/opencode/src/server/routes/instance/index.ts4
-rw-r--r--packages/opencode/test/server/httpapi-file.test.ts57
7 files changed, 207 insertions, 64 deletions
diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index 6c80dc65a..771c8b987 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -412,8 +412,9 @@ Current instance route inventory:
- `workspace` - `bridged`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
-- `file` - `later`
- good JSON-only candidate set, but larger than the current first-wave slices
+- `file` - `bridged` (partial)
+ bridged endpoints: `GET /file`, `GET /file/content`, `GET /file/status`
+ defer search endpoints first
- `mcp` - `later`
has JSON-only endpoints, but interactive OAuth/auth flows make it a worse early fit
- `session` - `defer`
@@ -449,7 +450,7 @@ Recommended near-term sequence:
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [x] port `GET /config` full read endpoint
- [x] port `workspace` read endpoints
-- [ ] port `file` JSON read endpoints
+- [x] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index ca791e412..05e2ce359 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -9,69 +9,63 @@ import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
-import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Log } from "../util"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
-
-export const Info = z
- .object({
- path: z.string(),
- added: z.number().int(),
- removed: z.number().int(),
- status: z.enum(["added", "deleted", "modified"]),
- })
- .meta({
- ref: "File",
- })
-
-export type Info = z.infer<typeof Info>
-
-export const Node = z
- .object({
- name: z.string(),
- path: z.string(),
- absolute: z.string(),
- type: z.enum(["file", "directory"]),
- ignored: z.boolean(),
- })
- .meta({
- ref: "FileNode",
- })
-export type Node = z.infer<typeof Node>
-
-export const Content = z
- .object({
- type: z.enum(["text", "binary"]),
- content: z.string(),
- diff: z.string().optional(),
- patch: z
- .object({
- oldFileName: z.string(),
- newFileName: z.string(),
- oldHeader: z.string().optional(),
- newHeader: z.string().optional(),
- hunks: z.array(
- z.object({
- oldStart: z.number(),
- oldLines: z.number(),
- newStart: z.number(),
- newLines: z.number(),
- lines: z.array(z.string()),
- }),
- ),
- index: z.string().optional(),
- })
- .optional(),
- encoding: z.literal("base64").optional(),
- mimeType: z.string().optional(),
- })
- .meta({
- ref: "FileContent",
- })
-export type Content = z.infer<typeof Content>
+import { zod } from "@/util/effect-zod"
+import { type DeepMutable, withStatics } from "@/util/schema"
+
+export const Info = Schema.Struct({
+ path: Schema.String,
+ added: Schema.Int,
+ removed: Schema.Int,
+ status: Schema.Literals(["added", "deleted", "modified"]),
+})
+ .annotate({ identifier: "File" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
+
+export const Node = Schema.Struct({
+ name: Schema.String,
+ path: Schema.String,
+ absolute: Schema.String,
+ type: Schema.Literals(["file", "directory"]),
+ ignored: Schema.Boolean,
+})
+ .annotate({ identifier: "FileNode" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
+
+const Hunk = Schema.Struct({
+ oldStart: Schema.Number,
+ oldLines: Schema.Number,
+ newStart: Schema.Number,
+ newLines: Schema.Number,
+ lines: Schema.Array(Schema.String),
+})
+
+const Patch = Schema.Struct({
+ oldFileName: Schema.String,
+ newFileName: Schema.String,
+ oldHeader: Schema.optional(Schema.String),
+ newHeader: Schema.optional(Schema.String),
+ hunks: Schema.Array(Hunk),
+ index: Schema.optional(Schema.String),
+})
+
+export const Content = Schema.Struct({
+ type: Schema.Literals(["text", "binary"]),
+ content: Schema.String,
+ diff: Schema.optional(Schema.String),
+ patch: Schema.optional(Patch),
+ encoding: Schema.optional(Schema.Literal("base64")),
+ mimeType: Schema.optional(Schema.String),
+})
+ .annotate({ identifier: "FileContent" })
+ .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Content = DeepMutable<Schema.Schema.Type<typeof Content>>
export const Event = {
Edited: BusEvent.define(
diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts
index f92fe6e7e..661322127 100644
--- a/packages/opencode/src/server/routes/instance/file.ts
+++ b/packages/opencode/src/server/routes/instance/file.ts
@@ -117,7 +117,7 @@ export const FileRoutes = lazy(() =>
description: "Files and directories",
content: {
"application/json": {
- schema: resolver(File.Node.array()),
+ schema: resolver(File.Node.zod.array()),
},
},
},
@@ -146,7 +146,7 @@ export const FileRoutes = lazy(() =>
description: "File content",
content: {
"application/json": {
- schema: resolver(File.Content),
+ schema: resolver(File.Content.zod),
},
},
},
@@ -175,7 +175,7 @@ export const FileRoutes = lazy(() =>
description: "File status",
content: {
"application/json": {
- schema: resolver(File.Info.array()),
+ schema: resolver(File.Info.zod.array()),
},
},
},
diff --git a/packages/opencode/src/server/routes/instance/httpapi/file.ts b/packages/opencode/src/server/routes/instance/httpapi/file.ts
new file mode 100644
index 000000000..eaf43862a
--- /dev/null
+++ b/packages/opencode/src/server/routes/instance/httpapi/file.ts
@@ -0,0 +1,84 @@
+import { File } from "@/file"
+import { Effect, Layer, Schema } from "effect"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+
+const FileQuery = Schema.Struct({
+ path: Schema.String,
+})
+
+export const FilePaths = {
+ list: "/file",
+ content: "/file/content",
+ status: "/file/status",
+} as const
+
+export const FileApi = HttpApi.make("file")
+ .add(
+ HttpApiGroup.make("file")
+ .add(
+ HttpApiEndpoint.get("list", FilePaths.list, {
+ query: FileQuery,
+ success: Schema.Array(File.Node),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "file.list",
+ summary: "List files",
+ description: "List files and directories in a specified path.",
+ }),
+ ),
+ HttpApiEndpoint.get("content", FilePaths.content, {
+ query: FileQuery,
+ success: File.Content,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "file.read",
+ summary: "Read file",
+ description: "Read the content of a specified file.",
+ }),
+ ),
+ HttpApiEndpoint.get("status", FilePaths.status, {
+ success: Schema.Array(File.Info),
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "file.status",
+ summary: "Get file status",
+ description: "Get the git status of all files in the project.",
+ }),
+ ),
+ )
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "file",
+ description: "Experimental HttpApi file routes.",
+ }),
+ ),
+ )
+ .annotateMerge(
+ OpenApi.annotations({
+ title: "opencode experimental HttpApi",
+ version: "0.0.1",
+ description: "Experimental HttpApi surface for selected instance routes.",
+ }),
+ )
+
+export const fileHandlers = Layer.unwrap(
+ Effect.gen(function* () {
+ const svc = yield* File.Service
+
+ const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
+ return yield* svc.list(ctx.query.path)
+ })
+
+ const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
+ return yield* svc.read(ctx.query.path)
+ })
+
+ const status = Effect.fn("FileHttpApi.status")(function* () {
+ return yield* svc.status()
+ })
+
+ return HttpApiBuilder.group(FileApi, "file", (handlers) =>
+ handlers.handle("list", list).handle("content", content).handle("status", status),
+ )
+ }),
+).pipe(Layer.provide(File.defaultLayer))
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 7b131d400..c57320c10 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -10,6 +10,7 @@ import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util"
import { ConfigApi, configHandlers } from "./config"
+import { FileApi, fileHandlers } from "./file"
import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { ProviderApi, providerHandlers } from "./provider"
@@ -114,9 +115,11 @@ const ProjectSecured = ProjectApi.middleware(Authorization)
const ProviderSecured = ProviderApi.middleware(Authorization)
const ConfigSecured = ConfigApi.middleware(Authorization)
const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
+const FileSecured = FileApi.middleware(Authorization)
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
+ HttpApiBuilder.layer(FileSecured).pipe(Layer.provide(fileHandlers)),
HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)),
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts
index e8a038fab..fba150116 100644
--- a/packages/opencode/src/server/routes/instance/index.ts
+++ b/packages/opencode/src/server/routes/instance/index.ts
@@ -16,6 +16,7 @@ import { QuestionRoutes } from "./question"
import { PermissionRoutes } from "./permission"
import { Flag } from "@/flag/flag"
import { ExperimentalHttpApiServer } from "./httpapi/server"
+import { FilePaths } from "./httpapi/file"
import { ProjectRoutes } from "./project"
import { SessionRoutes } from "./session"
import { PtyRoutes } from "./pty"
@@ -48,6 +49,9 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
app.get("/project", (c) => handler(c.req.raw, context))
app.get("/project/current", (c) => handler(c.req.raw, context))
+ app.get(FilePaths.list, (c) => handler(c.req.raw, context))
+ app.get(FilePaths.content, (c) => handler(c.req.raw, context))
+ app.get(FilePaths.status, (c) => handler(c.req.raw, context))
}
return app
diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts
new file mode 100644
index 000000000..5a9058cb6
--- /dev/null
+++ b/packages/opencode/test/server/httpapi-file.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { Context } from "effect"
+import path from "path"
+import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
+import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
+import { Instance } from "../../src/project/instance"
+import { Log } from "../../src/util"
+import { resetDatabase } from "../fixture/db"
+import { tmpdir } from "../fixture/fixture"
+
+void Log.init({ print: false })
+
+const context = Context.empty() as Context.Context<unknown>
+
+function request(route: string, directory: string, query?: Record<string, string>) {
+ const url = new URL(`http://localhost${route}`)
+ for (const [key, value] of Object.entries(query ?? {})) {
+ url.searchParams.set(key, value)
+ }
+ return ExperimentalHttpApiServer.webHandler().handler(
+ new Request(url, {
+ headers: {
+ "x-opencode-directory": directory,
+ },
+ }),
+ context,
+ )
+}
+
+afterEach(async () => {
+ await Instance.disposeAll()
+ await resetDatabase()
+})
+
+describe("file HttpApi", () => {
+ test("serves read endpoints", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Bun.write(path.join(tmp.path, "hello.txt"), "hello")
+
+ const [list, content, status] = await Promise.all([
+ request(FilePaths.list, tmp.path, { path: "." }),
+ request(FilePaths.content, tmp.path, { path: "hello.txt" }),
+ request(FilePaths.status, tmp.path),
+ ])
+
+ expect(list.status).toBe(200)
+ expect(await list.json()).toContainEqual(
+ expect.objectContaining({ name: "hello.txt", path: "hello.txt", type: "file" }),
+ )
+
+ expect(content.status).toBe(200)
+ expect(await content.json()).toMatchObject({ type: "text", content: "hello" })
+
+ expect(status.status).toBe(200)
+ expect(await status.json()).toContainEqual({ path: "hello.txt", added: 1, removed: 0, status: "added" })
+ })
+})