feat(httpapi): bridge catalog read endpoints (#24353)
This commit is contained in:
@@ -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 |
|
| `file` | `bridged` partial | list/content/status only |
|
||||||
| `mcp` | `bridged` partial | status only |
|
| `mcp` | `bridged` partial | status only |
|
||||||
| `workspace` | `bridged` | list, get, enter |
|
| `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 |
|
| experimental JSON routes | `next/later` | console, tool, worktree, resource, global session list |
|
||||||
| `session` | `later/special` | large stateful surface plus streaming |
|
| `session` | `later/special` | large stateful surface plus streaming |
|
||||||
| `sync` | `later` | process/control side effects |
|
| `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
|
## Next PRs
|
||||||
|
|
||||||
1. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths.
|
1. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths.
|
||||||
2. Continue porting top-level JSON reads.
|
2. Start the Effect OpenAPI/SDK generation path for already-bridged routes.
|
||||||
3. Start the Effect OpenAPI/SDK generation path for already-bridged routes.
|
|
||||||
|
|
||||||
## Checklist
|
## 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.
|
- [x] Add bridge-level auth and instance tests.
|
||||||
- [ ] Complete exact Hono route inventory.
|
- [ ] Complete exact Hono route inventory.
|
||||||
- [x] Resolve implemented-but-unmounted route groups.
|
- [x] Resolve implemented-but-unmounted route groups.
|
||||||
- [ ] Port remaining JSON routes.
|
- [x] Port remaining top-level JSON reads.
|
||||||
- [ ] Generate SDK/OpenAPI from Effect routes.
|
- [ ] Generate SDK/OpenAPI from Effect routes.
|
||||||
- [ ] Flip ported JSON routes to default-on with fallback.
|
- [ ] Flip ported JSON routes to default-on with fallback.
|
||||||
- [ ] Delete replaced Hono route implementations.
|
- [ ] Delete replaced Hono route implementations.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import z from "zod"
|
|||||||
import { Provider } from "../provider"
|
import { Provider } from "../provider"
|
||||||
import { ModelID, ProviderID } from "../provider/schema"
|
import { ModelID, ProviderID } from "../provider/schema"
|
||||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||||
import { Instance } from "../project/instance"
|
|
||||||
import { Truncate } from "../tool"
|
import { Truncate } from "../tool"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { ProviderTransform } from "../provider"
|
import { ProviderTransform } from "../provider"
|
||||||
@@ -19,37 +18,37 @@ import { Global } from "@opencode-ai/core/global"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Effect, Context, Layer } from "effect"
|
import { Effect, Context, Layer, Schema } from "effect"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
import * as Option from "effect/Option"
|
import * as Option from "effect/Option"
|
||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { withStatics, type DeepMutable } from "@/util/schema"
|
||||||
|
|
||||||
export const Info = z
|
export const Info = Schema.Struct({
|
||||||
.object({
|
name: Schema.String,
|
||||||
name: z.string(),
|
description: Schema.optional(Schema.String),
|
||||||
description: z.string().optional(),
|
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||||
mode: z.enum(["subagent", "primary", "all"]),
|
native: Schema.optional(Schema.Boolean),
|
||||||
native: z.boolean().optional(),
|
hidden: Schema.optional(Schema.Boolean),
|
||||||
hidden: z.boolean().optional(),
|
topP: Schema.optional(Schema.Number),
|
||||||
topP: z.number().optional(),
|
temperature: Schema.optional(Schema.Number),
|
||||||
temperature: z.number().optional(),
|
color: Schema.optional(Schema.String),
|
||||||
color: z.string().optional(),
|
permission: Permission.Ruleset,
|
||||||
permission: Permission.Ruleset.zod,
|
model: Schema.optional(
|
||||||
model: z
|
Schema.Struct({
|
||||||
.object({
|
modelID: ModelID,
|
||||||
modelID: ModelID.zod,
|
providerID: ProviderID,
|
||||||
providerID: ProviderID.zod,
|
}),
|
||||||
|
),
|
||||||
|
variant: Schema.optional(Schema.String),
|
||||||
|
prompt: Schema.optional(Schema.String),
|
||||||
|
options: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
|
steps: Schema.optional(Schema.Number),
|
||||||
})
|
})
|
||||||
.optional(),
|
.annotate({ identifier: "Agent" })
|
||||||
variant: z.string().optional(),
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
prompt: z.string().optional(),
|
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||||
options: z.record(z.string(), z.any()),
|
|
||||||
steps: z.number().int().positive().optional(),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "Agent",
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: (agent: string) => Effect.Effect<Info>
|
readonly get: (agent: string) => Effect.Effect<Info>
|
||||||
@@ -79,7 +78,7 @@ export const layer = Layer.effect(
|
|||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Agent.state")(function* (_ctx) {
|
Effect.fn("Agent.state")(function* (ctx) {
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const skillDirs = yield* skill.dirs()
|
const skillDirs = yield* skill.dirs()
|
||||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||||
@@ -136,7 +135,7 @@ export const layer = Layer.effect(
|
|||||||
edit: {
|
edit: {
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
[path.join(".opencode", "plans", "*.md")]: "allow",
|
[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,
|
user,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { InstanceContext } from "@/project/instance"
|
|||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Effect, Layer, Context, Schema } from "effect"
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
@@ -27,25 +29,22 @@ export const Event = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Info = z
|
export const Info = Schema.Struct({
|
||||||
.object({
|
name: Schema.String,
|
||||||
name: z.string(),
|
description: Schema.optional(Schema.String),
|
||||||
description: z.string().optional(),
|
agent: Schema.optional(Schema.String),
|
||||||
agent: z.string().optional(),
|
model: Schema.optional(Schema.String),
|
||||||
model: z.string().optional(),
|
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
|
||||||
source: z.enum(["command", "mcp", "skill"]).optional(),
|
// Some command templates are lazy promises from MCP prompt resolution.
|
||||||
// workaround for zod not supporting async functions natively so we use getters
|
template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
|
||||||
// https://zod.dev/v4/changelog?id=zfunction
|
subtask: Schema.optional(Schema.Boolean),
|
||||||
template: z.promise(z.string()).or(z.string()),
|
hints: Schema.Array(Schema.String),
|
||||||
subtask: z.boolean().optional(),
|
|
||||||
hints: z.array(z.string()),
|
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "Command",
|
|
||||||
})
|
})
|
||||||
|
.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
|
// 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) {
|
export function hints(template: string) {
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
|
|||||||
@@ -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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { mergeDeep } from "remeda"
|
import { mergeDeep } from "remeda"
|
||||||
import z from "zod"
|
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { Log } from "../util"
|
import { Log } from "../util"
|
||||||
import * as Formatter from "./formatter"
|
import * as Formatter from "./formatter"
|
||||||
|
import { zod } from "@/util/effect-zod"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const log = Log.create({ service: "format" })
|
const log = Log.create({ service: "format" })
|
||||||
|
|
||||||
export const Status = z
|
export const Status = Schema.Struct({
|
||||||
.object({
|
name: Schema.String,
|
||||||
name: z.string(),
|
extensions: Schema.Array(Schema.String),
|
||||||
extensions: z.string().array(),
|
enabled: Schema.Boolean,
|
||||||
enabled: z.boolean(),
|
|
||||||
})
|
})
|
||||||
.meta({
|
.annotate({ identifier: "FormatterStatus" })
|
||||||
ref: "FormatterStatus",
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
})
|
export type Status = Schema.Schema.Type<typeof Status>
|
||||||
export type Status = z.infer<typeof Status>
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
readonly init: () => Effect.Effect<void>
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ export const Action = Schema.Literals(["allow", "deny", "ask"])
|
|||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type Action = Schema.Schema.Type<typeof Action>
|
export type Action = Schema.Schema.Type<typeof Action>
|
||||||
|
|
||||||
export class Rule extends Schema.Class<Rule>("PermissionRule")({
|
export const Rule = Schema.Struct({
|
||||||
permission: Schema.String,
|
permission: Schema.String,
|
||||||
pattern: Schema.String,
|
pattern: Schema.String,
|
||||||
action: Action,
|
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))
|
export const Ruleset = Schema.mutable(Schema.Array(Rule))
|
||||||
.annotate({ identifier: "PermissionRuleset" })
|
.annotate({ identifier: "PermissionRuleset" })
|
||||||
|
|||||||
@@ -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 { Global } from "@opencode-ai/core/global"
|
||||||
|
import { LSP } from "@/lsp"
|
||||||
import { Vcs } from "@/project"
|
import { Vcs } from "@/project"
|
||||||
|
import { Skill } from "@/skill"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
@@ -21,6 +26,11 @@ export const InstancePaths = {
|
|||||||
path: "/path",
|
path: "/path",
|
||||||
vcs: "/vcs",
|
vcs: "/vcs",
|
||||||
vcsDiff: "/vcs/diff",
|
vcsDiff: "/vcs/diff",
|
||||||
|
command: "/command",
|
||||||
|
agent: "/agent",
|
||||||
|
skill: "/skill",
|
||||||
|
lsp: "/lsp",
|
||||||
|
formatter: "/formatter",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const InstanceApi = HttpApi.make("instance")
|
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.",
|
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(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -76,6 +131,11 @@ export const InstanceApi = HttpApi.make("instance")
|
|||||||
|
|
||||||
export const instanceHandlers = Layer.unwrap(
|
export const instanceHandlers = Layer.unwrap(
|
||||||
Effect.gen(function* () {
|
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 vcs = yield* Vcs.Service
|
||||||
|
|
||||||
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
|
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
|
||||||
@@ -98,8 +158,43 @@ export const instanceHandlers = Layer.unwrap(
|
|||||||
return yield* vcs.diff(ctx.query.mode)
|
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) =>
|
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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|||||||
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
|
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
|
||||||
app.get(InstancePaths.vcs, (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.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))
|
app.get(McpPaths.status, (c) => handler(c.req.raw, context))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +206,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|||||||
description: "List of commands",
|
description: "List of commands",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"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",
|
description: "List of agents",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"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",
|
description: "List of skills",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"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",
|
description: "Formatter status",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Format.Status.array()),
|
schema: resolver(Format.Status.zod.array()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import os from "os"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import z from "zod"
|
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 { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import type { Agent } from "@/agent/agent"
|
import type { Agent } from "@/agent/agent"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
@@ -23,13 +25,14 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
|||||||
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
||||||
const SKILL_PATTERN = "**/SKILL.md"
|
const SKILL_PATTERN = "**/SKILL.md"
|
||||||
|
|
||||||
export const Info = z.object({
|
export const Info = Schema.Struct({
|
||||||
name: z.string(),
|
name: Schema.String,
|
||||||
description: z.string(),
|
description: Schema.String,
|
||||||
location: z.string(),
|
location: Schema.String,
|
||||||
content: z.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(
|
export const InvalidError = NamedError.create(
|
||||||
"SkillInvalidError",
|
"SkillInvalidError",
|
||||||
@@ -91,7 +94,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I
|
|||||||
|
|
||||||
if (!md) return
|
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 (!parsed.success) return
|
||||||
|
|
||||||
if (state.skills[parsed.data.name]) {
|
if (state.skills[parsed.data.name]) {
|
||||||
|
|||||||
@@ -50,4 +50,31 @@ describe("instance HttpApi", () => {
|
|||||||
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
|
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([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user