diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 6730957f23..df9d4e3994 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -3,7 +3,8 @@ import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" import { Git } from "@/git" -import { Effect, Layer, Context } from "effect" +import { zod } from "@/util/effect-zod" +import { Effect, Layer, Context, Schema } from "effect" import * as Stream from "effect/Stream" import { formatPatch, structuredPatch } from "diff" import fuzzysort from "fuzzysort" @@ -17,62 +18,56 @@ import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" export namespace File { - 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 class Info extends Schema.Class("File")({ + path: Schema.String, + added: Schema.Number, + removed: Schema.Number, + status: Schema.Union([Schema.Literal("added"), Schema.Literal("deleted"), Schema.Literal("modified")]), + }) { + static readonly zod = zod(this) + } - export type Info = z.infer + export class Node extends Schema.Class("FileNode")({ + name: Schema.String, + path: Schema.String, + absolute: Schema.String, + type: Schema.Union([Schema.Literal("file"), Schema.Literal("directory")]), + ignored: Schema.Boolean, + }) { + static readonly zod = zod(this) + } - 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 + export class Hunk extends Schema.Class("FileContentHunk")({ + oldStart: Schema.Number, + oldLines: Schema.Number, + newStart: Schema.Number, + newLines: Schema.Number, + lines: Schema.Array(Schema.String), + }) { + static readonly zod = zod(this) + } - 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 + export class Patch extends Schema.Class("FileContentPatch")({ + 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), + }) { + static readonly zod = zod(this) + } + + export class Content extends Schema.Class("FileContent")({ + type: Schema.Union([Schema.Literal("text"), Schema.Literal("binary")]), + content: Schema.String, + diff: Schema.optional(Schema.String), + patch: Schema.optional(Patch), + encoding: Schema.optional(Schema.Literal("base64")), + mimeType: Schema.optional(Schema.String), + }) { + static readonly zod = zod(this) + } export const Event = { Edited: BusEvent.define( diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts index db5e227770..bf5072c76c 100644 --- a/packages/opencode/src/server/instance/file.ts +++ b/packages/opencode/src/server/instance/file.ts @@ -126,7 +126,7 @@ export const FileRoutes = lazy(() => description: "Files and directories", content: { "application/json": { - schema: resolver(File.Node.array()), + schema: resolver(z.array(File.Node.zod)), }, }, }, @@ -159,7 +159,7 @@ export const FileRoutes = lazy(() => description: "File content", content: { "application/json": { - schema: resolver(File.Content), + schema: resolver(File.Content.zod), }, }, }, @@ -192,7 +192,7 @@ export const FileRoutes = lazy(() => description: "File status", content: { "application/json": { - schema: resolver(File.Info.array()), + schema: resolver(z.array(File.Info.zod)), }, }, }, diff --git a/packages/opencode/src/server/instance/httpapi/file.ts b/packages/opencode/src/server/instance/httpapi/file.ts new file mode 100644 index 0000000000..d637912bb0 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/file.ts @@ -0,0 +1,96 @@ +import { AppLayer } from "@/effect/app-runtime" +import { memoMap } from "@/effect/run-service" +import { File } from "@/file" +import { lazy } from "@/util/lazy" +import { Effect, Layer, Schema } from "effect" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import type { Handler } from "hono" + +const root = "/experimental/httpapi/file" + +const Api = HttpApi.make("file") + .add( + HttpApiGroup.make("file") + .add( + HttpApiEndpoint.get("list", root, { + query: { path: Schema.optional(Schema.String) }, + 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", `${root}/content`, { + query: { path: Schema.String }, + success: File.Content, + }).annotateMerge( + OpenApi.annotations({ + identifier: "file.read", + summary: "Read file", + description: "Read the content of a specified file.", + }), + ), + HttpApiEndpoint.get("status", `${root}/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.", + }), + ) + +const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path?: string } }) { + const svc = yield* File.Service + return Schema.decodeUnknownSync(Schema.Array(File.Node))(yield* svc.list(ctx.query.path)) +}) + +const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) { + const svc = yield* File.Service + return Schema.decodeUnknownSync(File.Content)(yield* svc.read(ctx.query.path)) +}) + +const status = Effect.fn("FileHttpApi.status")(function* () { + const svc = yield* File.Service + return Schema.decodeUnknownSync(Schema.Array(File.Info))(yield* svc.status()) +}) + +const FileLive = HttpApiBuilder.group(Api, "file", (handlers) => + handlers.handle("list", list).handle("content", content).handle("status", status), +) + +const web = lazy(() => + HttpRouter.toWebHandler( + Layer.mergeAll( + AppLayer, + HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe( + Layer.provide(FileLive), + Layer.provide(HttpServer.layerServices), + ), + ), + { + disableLogger: true, + memoMap, + }, + ), +) + +export const FileHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw) diff --git a/packages/opencode/src/server/instance/httpapi/index.ts b/packages/opencode/src/server/instance/httpapi/index.ts index 523041de84..f2f8518972 100644 --- a/packages/opencode/src/server/instance/httpapi/index.ts +++ b/packages/opencode/src/server/instance/httpapi/index.ts @@ -1,7 +1,12 @@ import { lazy } from "@/util/lazy" import { Hono } from "hono" +import { FileHttpApiHandler } from "./file" import { QuestionHttpApiHandler } from "./question" export const HttpApiRoutes = lazy(() => - new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler), + new Hono() + .all("/question", QuestionHttpApiHandler) + .all("/question/*", QuestionHttpApiHandler) + .all("/file", FileHttpApiHandler) + .all("/file/*", FileHttpApiHandler), ) diff --git a/packages/opencode/test/server/file-httpapi.test.ts b/packages/opencode/test/server/file-httpapi.test.ts new file mode 100644 index 0000000000..e849a1acee --- /dev/null +++ b/packages/opencode/test/server/file-httpapi.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { Server } from "../../src/server/server" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("experimental file httpapi", () => { + test("lists files, reads content, reports status, and serves docs", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "note.txt"), "hello") + }, + }) + const app = Server.Default().app + const headers = { + "content-type": "application/json", + "x-opencode-directory": tmp.path, + } + + const list = await app.request("/experimental/httpapi/file?path=.", { headers }) + expect(list.status).toBe(200) + const items = await list.json() + expect(items.some((item: { name: string }) => item.name === "note.txt")).toBe(true) + + const read = await app.request("/experimental/httpapi/file/content?path=note.txt", { headers }) + expect(read.status).toBe(200) + const content = await read.json() + expect(content.type).toBe("text") + expect(content.content).toContain("hello") + + const status = await app.request("/experimental/httpapi/file/status", { headers }) + expect(status.status).toBe(200) + expect(Array.isArray(await status.json())).toBe(true) + + const doc = await app.request("/experimental/httpapi/file/doc", { headers }) + expect(doc.status).toBe(200) + const spec = await doc.json() + expect(spec.paths["/experimental/httpapi/file"]?.get?.operationId).toBe("file.list") + expect(spec.paths["/experimental/httpapi/file/content"]?.get?.operationId).toBe("file.read") + expect(spec.paths["/experimental/httpapi/file/status"]?.get?.operationId).toBe("file.status") + }) +})