Просмотр исходного кода

feat(httpapi): bridge catalog read endpoints (#24353)

Kit Langton 4 месяцев назад
Родитель
Сommit
eb0219988b

+ 3 - 4
packages/opencode/specs/effect/http-api.md

@@ -140,7 +140,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 | `file`                   | `bridged` partial | list/content/status only                                       |
 | `mcp`                    | `bridged` partial | status only                                                    |
 | `workspace`              | `bridged`         | list, get, enter                                               |
-| top-level instance reads | `bridged` partial | path and vcs reads; command, agent, skill, lsp, formatter next |
+| top-level instance reads | `bridged`         | path, vcs, command, agent, skill, lsp, formatter              |
 | experimental JSON routes | `next/later`      | console, tool, worktree, resource, global session list         |
 | `session`                | `later/special`   | large stateful surface plus streaming                          |
 | `sync`                   | `later`           | process/control side effects                                   |
@@ -151,8 +151,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 ## Next PRs
 
 1. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths.
-2. Continue porting top-level JSON reads.
-3. Start the Effect OpenAPI/SDK generation path for already-bridged routes.
+2. Start the Effect OpenAPI/SDK generation path for already-bridged routes.
 
 ## Checklist
 
@@ -165,7 +164,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 - [x] Add bridge-level auth and instance tests.
 - [ ] Complete exact Hono route inventory.
 - [x] Resolve implemented-but-unmounted route groups.
-- [ ] Port remaining JSON routes.
+- [x] Port remaining top-level JSON reads.
 - [ ] Generate SDK/OpenAPI from Effect routes.
 - [ ] Flip ported JSON routes to default-on with fallback.
 - [ ] Delete replaced Hono route implementations.

+ 29 - 30
packages/opencode/src/agent/agent.ts

@@ -3,7 +3,6 @@ import z from "zod"
 import { Provider } from "../provider"
 import { ModelID, ProviderID } from "../provider/schema"
 import { generateObject, streamObject, type ModelMessage } from "ai"
-import { Instance } from "../project/instance"
 import { Truncate } from "../tool"
 import { Auth } from "../auth"
 import { ProviderTransform } from "../provider"
@@ -19,37 +18,37 @@ import { Global } from "@opencode-ai/core/global"
 import path from "path"
 import { Plugin } from "@/plugin"
 import { Skill } from "../skill"
-import { Effect, Context, Layer } from "effect"
+import { Effect, Context, Layer, Schema } from "effect"
 import { InstanceState } from "@/effect"
 import * as Option from "effect/Option"
 import * as OtelTracer from "@effect/opentelemetry/Tracer"
+import { zod } from "@/util/effect-zod"
+import { withStatics, type DeepMutable } from "@/util/schema"
 
-export const Info = z
-  .object({
-    name: z.string(),
-    description: z.string().optional(),
-    mode: z.enum(["subagent", "primary", "all"]),
-    native: z.boolean().optional(),
-    hidden: z.boolean().optional(),
-    topP: z.number().optional(),
-    temperature: z.number().optional(),
-    color: z.string().optional(),
-    permission: Permission.Ruleset.zod,
-    model: z
-      .object({
-        modelID: ModelID.zod,
-        providerID: ProviderID.zod,
-      })
-      .optional(),
-    variant: z.string().optional(),
-    prompt: z.string().optional(),
-    options: z.record(z.string(), z.any()),
-    steps: z.number().int().positive().optional(),
-  })
-  .meta({
-    ref: "Agent",
-  })
-export type Info = z.infer<typeof Info>
+export const Info = Schema.Struct({
+  name: Schema.String,
+  description: Schema.optional(Schema.String),
+  mode: Schema.Literals(["subagent", "primary", "all"]),
+  native: Schema.optional(Schema.Boolean),
+  hidden: Schema.optional(Schema.Boolean),
+  topP: Schema.optional(Schema.Number),
+  temperature: Schema.optional(Schema.Number),
+  color: Schema.optional(Schema.String),
+  permission: Permission.Ruleset,
+  model: Schema.optional(
+    Schema.Struct({
+      modelID: ModelID,
+      providerID: ProviderID,
+    }),
+  ),
+  variant: Schema.optional(Schema.String),
+  prompt: Schema.optional(Schema.String),
+  options: Schema.Record(Schema.String, Schema.Unknown),
+  steps: Schema.optional(Schema.Number),
+})
+  .annotate({ identifier: "Agent" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
 
 export interface Interface {
   readonly get: (agent: string) => Effect.Effect<Info>
@@ -79,7 +78,7 @@ export const layer = Layer.effect(
     const provider = yield* Provider.Service
 
     const state = yield* InstanceState.make<State>(
-      Effect.fn("Agent.state")(function* (_ctx) {
+      Effect.fn("Agent.state")(function* (ctx) {
         const cfg = yield* config.get()
         const skillDirs = yield* skill.dirs()
         const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
@@ -136,7 +135,7 @@ export const layer = Layer.effect(
                 edit: {
                   "*": "deny",
                   [path.join(".opencode", "plans", "*.md")]: "allow",
-                  [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
+                  [path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
                 },
               }),
               user,

+ 16 - 17
packages/opencode/src/command/index.ts

@@ -5,6 +5,8 @@ import type { InstanceContext } from "@/project/instance"
 import { SessionID, MessageID } from "@/session/schema"
 import { Effect, Layer, Context, Schema } from "effect"
 import z from "zod"
+import { zod, ZodOverride } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 import { Config } from "../config"
 import { MCP } from "../mcp"
 import { Skill } from "../skill"
@@ -27,25 +29,22 @@ export const Event = {
   ),
 }
 
-export const Info = z
-  .object({
-    name: z.string(),
-    description: z.string().optional(),
-    agent: z.string().optional(),
-    model: z.string().optional(),
-    source: z.enum(["command", "mcp", "skill"]).optional(),
-    // workaround for zod not supporting async functions natively so we use getters
-    // https://zod.dev/v4/changelog?id=zfunction
-    template: z.promise(z.string()).or(z.string()),
-    subtask: z.boolean().optional(),
-    hints: z.array(z.string()),
-  })
-  .meta({
-    ref: "Command",
-  })
+export const Info = Schema.Struct({
+  name: Schema.String,
+  description: Schema.optional(Schema.String),
+  agent: Schema.optional(Schema.String),
+  model: Schema.optional(Schema.String),
+  source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
+  // Some command templates are lazy promises from MCP prompt resolution.
+  template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
+  subtask: Schema.optional(Schema.Boolean),
+  hints: Schema.Array(Schema.String),
+})
+  .annotate({ identifier: "Command" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
 
 // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
-export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
+export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
 
 export function hints(template: string) {
   const result: string[] = []

+ 11 - 12
packages/opencode/src/format/index.ts

@@ -1,26 +1,25 @@
-import { Effect, Layer, Context } from "effect"
+import { Effect, Layer, Context, Schema } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 import { InstanceState } from "@/effect"
 import path from "path"
 import { mergeDeep } from "remeda"
-import z from "zod"
 import { Config } from "../config"
 import { Log } from "../util"
 import * as Formatter from "./formatter"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
 const log = Log.create({ service: "format" })
 
-export const Status = z
-  .object({
-    name: z.string(),
-    extensions: z.string().array(),
-    enabled: z.boolean(),
-  })
-  .meta({
-    ref: "FormatterStatus",
-  })
-export type Status = z.infer<typeof Status>
+export const Status = Schema.Struct({
+  name: Schema.String,
+  extensions: Schema.Array(Schema.String),
+  enabled: Schema.Boolean,
+})
+  .annotate({ identifier: "FormatterStatus" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Status = Schema.Schema.Type<typeof Status>
 
 export interface Interface {
   readonly init: () => Effect.Effect<void>

+ 5 - 4
packages/opencode/src/permission/index.ts

@@ -22,13 +22,14 @@ export const Action = Schema.Literals(["allow", "deny", "ask"])
   .pipe(withStatics((s) => ({ zod: zod(s) })))
 export type Action = Schema.Schema.Type<typeof Action>
 
-export class Rule extends Schema.Class<Rule>("PermissionRule")({
+export const Rule = Schema.Struct({
   permission: Schema.String,
   pattern: Schema.String,
   action: Action,
-}) {
-  static readonly zod = zod(this)
-}
+})
+  .annotate({ identifier: "PermissionRule" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Rule = Schema.Schema.Type<typeof Rule>
 
 export const Ruleset = Schema.mutable(Schema.Array(Rule))
   .annotate({ identifier: "PermissionRuleset" })

+ 97 - 2
packages/opencode/src/server/routes/instance/httpapi/instance.ts

@@ -1,5 +1,10 @@
+import { Agent } from "@/agent/agent"
+import { Command } from "@/command"
+import { Format } from "@/format"
 import { Global } from "@opencode-ai/core/global"
+import { LSP } from "@/lsp"
 import { Vcs } from "@/project"
+import { Skill } from "@/skill"
 import * as InstanceState from "@/effect/instance-state"
 import { Effect, Layer, Schema } from "effect"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -21,6 +26,11 @@ export const InstancePaths = {
   path: "/path",
   vcs: "/vcs",
   vcsDiff: "/vcs/diff",
+  command: "/command",
+  agent: "/agent",
+  skill: "/skill",
+  lsp: "/lsp",
+  formatter: "/formatter",
 } as const
 
 export const InstanceApi = HttpApi.make("instance")
@@ -57,6 +67,51 @@ export const InstanceApi = HttpApi.make("instance")
             description: "Retrieve the current git diff for the working tree or against the default branch.",
           }),
         ),
+        HttpApiEndpoint.get("command", InstancePaths.command, {
+          success: Schema.Array(Command.Info),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "command.list",
+            summary: "List commands",
+            description: "Get a list of all available commands in the OpenCode system.",
+          }),
+        ),
+        HttpApiEndpoint.get("agent", InstancePaths.agent, {
+          success: Schema.Array(Agent.Info),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "app.agents",
+            summary: "List agents",
+            description: "Get a list of all available AI agents in the OpenCode system.",
+          }),
+        ),
+        HttpApiEndpoint.get("skill", InstancePaths.skill, {
+          success: Schema.Array(Skill.Info),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "app.skills",
+            summary: "List skills",
+            description: "Get a list of all available skills in the OpenCode system.",
+          }),
+        ),
+        HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
+          success: Schema.Array(LSP.Status),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "lsp.status",
+            summary: "Get LSP status",
+            description: "Get LSP server status",
+          }),
+        ),
+        HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
+          success: Schema.Array(Format.Status),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "formatter.status",
+            summary: "Get formatter status",
+            description: "Get formatter status",
+          }),
+        ),
       )
       .annotateMerge(
         OpenApi.annotations({
@@ -76,6 +131,11 @@ export const InstanceApi = HttpApi.make("instance")
 
 export const instanceHandlers = Layer.unwrap(
   Effect.gen(function* () {
+    const agent = yield* Agent.Service
+    const command = yield* Command.Service
+    const format = yield* Format.Service
+    const lsp = yield* LSP.Service
+    const skill = yield* Skill.Service
     const vcs = yield* Vcs.Service
 
     const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
@@ -98,8 +158,43 @@ export const instanceHandlers = Layer.unwrap(
       return yield* vcs.diff(ctx.query.mode)
     })
 
+    const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
+      return yield* command.list()
+    })
+
+    const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
+      return yield* agent.list()
+    })
+
+    const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
+      return yield* skill.all()
+    })
+
+    const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
+      return yield* lsp.status()
+    })
+
+    const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
+      return yield* format.status()
+    })
+
     return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
-      handlers.handle("path", getPath).handle("vcs", getVcs).handle("vcsDiff", getVcsDiff),
+      handlers
+        .handle("path", getPath)
+        .handle("vcs", getVcs)
+        .handle("vcsDiff", getVcsDiff)
+        .handle("command", getCommand)
+        .handle("agent", getAgent)
+        .handle("skill", getSkill)
+        .handle("lsp", getLsp)
+        .handle("formatter", getFormatter),
     )
   }),
-).pipe(Layer.provide(Vcs.defaultLayer))
+).pipe(
+  Layer.provide(Agent.defaultLayer),
+  Layer.provide(Command.defaultLayer),
+  Layer.provide(Format.defaultLayer),
+  Layer.provide(LSP.defaultLayer),
+  Layer.provide(Skill.defaultLayer),
+  Layer.provide(Vcs.defaultLayer),
+)

+ 9 - 4
packages/opencode/src/server/routes/instance/index.ts

@@ -57,6 +57,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
     app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context))
+    app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
     app.get(McpPaths.status, (c) => handler(c.req.raw, context))
   }
 
@@ -201,7 +206,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
             description: "List of commands",
             content: {
               "application/json": {
-                schema: resolver(Command.Info.array()),
+                schema: resolver(Command.Info.zod.array()),
               },
             },
           },
@@ -224,7 +229,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
             description: "List of agents",
             content: {
               "application/json": {
-                schema: resolver(Agent.Info.array()),
+                schema: resolver(Agent.Info.zod.array()),
               },
             },
           },
@@ -247,7 +252,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
             description: "List of skills",
             content: {
               "application/json": {
-                schema: resolver(Skill.Info.array()),
+                schema: resolver(Skill.Info.zod.array()),
               },
             },
           },
@@ -293,7 +298,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
             description: "Formatter status",
             content: {
               "application/json": {
-                schema: resolver(Format.Status.array()),
+                schema: resolver(Format.Status.zod.array()),
               },
             },
           },

+ 11 - 8
packages/opencode/src/skill/index.ts

@@ -2,7 +2,9 @@ import os from "os"
 import path from "path"
 import { pathToFileURL } from "url"
 import z from "zod"
-import { Effect, Layer, Context } from "effect"
+import { Effect, Layer, Context, Schema } from "effect"
+import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 import { NamedError } from "@opencode-ai/core/util/error"
 import type { Agent } from "@/agent/agent"
 import { Bus } from "@/bus"
@@ -23,13 +25,14 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
 const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
 const SKILL_PATTERN = "**/SKILL.md"
 
-export const Info = z.object({
-  name: z.string(),
-  description: z.string(),
-  location: z.string(),
-  content: z.string(),
+export const Info = Schema.Struct({
+  name: Schema.String,
+  description: Schema.String,
+  location: Schema.String,
+  content: Schema.String,
 })
-export type Info = z.infer<typeof Info>
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type<typeof Info>
 
 export const InvalidError = NamedError.create(
   "SkillInvalidError",
@@ -91,7 +94,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I
 
   if (!md) return
 
-  const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
+  const parsed = z.object({ name: z.string(), description: z.string() }).safeParse(md.data)
   if (!parsed.success) return
 
   if (state.skills[parsed.data.name]) {

+ 27 - 0
packages/opencode/test/server/httpapi-instance.test.ts

@@ -50,4 +50,31 @@ describe("instance HttpApi", () => {
       expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
     )
   })
+
+  test("serves catalog read endpoints through Hono bridge", async () => {
+    await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
+
+    const [commands, agents, skills, lsp, formatter] = await Promise.all([
+      app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
+      app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
+      app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
+      app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
+      app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
+    ])
+
+    expect(commands.status).toBe(200)
+    expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
+
+    expect(agents.status).toBe(200)
+    expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
+
+    expect(skills.status).toBe(200)
+    expect(await skills.json()).toBeArray()
+
+    expect(lsp.status).toBe(200)
+    expect(await lsp.json()).toEqual([])
+
+    expect(formatter.status).toBe(200)
+    expect(await formatter.json()).toEqual([])
+  })
 })