Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions packages/opencode/src/server/instance/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type ToolListQuery = {
model: string
}

const getConsoleState = Effect.fn("ExperimentalRoutes.console.get")(function* () {
export const getConsoleState = Effect.fn("ExperimentalRoutes.console.get")(function* () {
const config = yield* Config.Service
const account = yield* Account.Service
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
Expand All @@ -56,7 +56,7 @@ const getConsoleState = Effect.fn("ExperimentalRoutes.console.get")(function* ()
}
})

const listConsoleOrgs = Effect.fn("ExperimentalRoutes.console.listOrgs")(function* () {
export const listConsoleOrgs = Effect.fn("ExperimentalRoutes.console.listOrgs")(function* () {
const account = yield* Account.Service
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
concurrency: "unbounded",
Expand All @@ -76,18 +76,18 @@ const listConsoleOrgs = Effect.fn("ExperimentalRoutes.console.listOrgs")(functio
}
})

const switchConsoleOrg = Effect.fn("ExperimentalRoutes.console.switchOrg")(function* (body: ConsoleSwitchBody) {
export const switchConsoleOrg = Effect.fn("ExperimentalRoutes.console.switchOrg")(function* (body: ConsoleSwitchBody) {
const account = yield* Account.Service
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
return true
})

const listToolIDs = Effect.fn("ExperimentalRoutes.tool.ids")(function* () {
export const listToolIDs = Effect.fn("ExperimentalRoutes.tool.ids")(function* () {
const registry = yield* ToolRegistry.Service
return yield* registry.ids()
})

const listTools = Effect.fn("ExperimentalRoutes.tool.list")(function* ({ provider, model }: ToolListQuery) {
export const listTools = Effect.fn("ExperimentalRoutes.tool.list")(function* ({ provider, model }: ToolListQuery) {
const registry = yield* ToolRegistry.Service
const agents = yield* Agent.Service
const agent = yield* agents.get(yield* agents.defaultAgent())
Expand All @@ -104,29 +104,29 @@ const listTools = Effect.fn("ExperimentalRoutes.tool.list")(function* ({ provide
}))
})

const createWorktree = Effect.fn("ExperimentalRoutes.worktree.create")(function* (body?: Worktree.CreateInput) {
export const createWorktree = Effect.fn("ExperimentalRoutes.worktree.create")(function* (body?: Worktree.CreateInput) {
const worktrees = yield* Worktree.Service
return yield* worktrees.create(body)
})

const listWorktrees = Effect.fn("ExperimentalRoutes.worktree.list")(function* () {
export const listWorktrees = Effect.fn("ExperimentalRoutes.worktree.list")(function* () {
const worktrees = yield* Worktree.Service
return yield* worktrees.list()
})

const removeWorktree = Effect.fn("ExperimentalRoutes.worktree.remove")(function* (body: Worktree.RemoveInput) {
export const removeWorktree = Effect.fn("ExperimentalRoutes.worktree.remove")(function* (body: Worktree.RemoveInput) {
const worktrees = yield* Worktree.Service
yield* worktrees.remove(body)
return true
})

const resetWorktree = Effect.fn("ExperimentalRoutes.worktree.reset")(function* (body: Worktree.ResetInput) {
export const resetWorktree = Effect.fn("ExperimentalRoutes.worktree.reset")(function* (body: Worktree.ResetInput) {
const worktrees = yield* Worktree.Service
yield* worktrees.reset(body)
return true
})

const listResources = Effect.fn("ExperimentalRoutes.resource.list")(function* () {
export const listResources = Effect.fn("ExperimentalRoutes.resource.list")(function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { ConsoleState } from "@/config/console-state"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { BadRequestError } from "./common"

const root = "/experimental"

const ConsoleOrgOption = Schema.Struct({
accountID: Schema.String,
accountEmail: Schema.String,
accountUrl: Schema.String,
orgID: Schema.String,
orgName: Schema.String,
active: Schema.Boolean,
})

const ConsoleOrgList = Schema.Struct({
orgs: Schema.Array(ConsoleOrgOption),
})

const ConsoleSwitchPayload = Schema.Struct({
accountID: Schema.String,
orgID: Schema.String,
})

const ToolListQuery = Schema.Struct({
provider: Schema.String,
model: Schema.String,
})

const ToolListItem = Schema.Struct({
id: Schema.String,
description: Schema.String,
parameters: Schema.Any,
})

const McpResource = Schema.Struct({
name: Schema.String,
uri: Schema.String,
description: Schema.optionalKey(Schema.String),
mimeType: Schema.optionalKey(Schema.String),
client: Schema.String,
})

const WorktreeInfo = Schema.Struct({
name: Schema.String,
branch: Schema.String,
directory: Schema.String,
source: Schema.optionalKey(Schema.Literals(["created", "existing"])),
})

const WorktreeCreatePayload = Schema.Struct({
name: Schema.optionalKey(Schema.String),
startCommand: Schema.optionalKey(Schema.String),
})

const WorktreeDirectoryPayload = Schema.Struct({
directory: Schema.String,
})

export const ExperimentalPaths = {
console: `${root}/console`,
consoleOrgs: `${root}/console/orgs`,
consoleSwitch: `${root}/console/switch`,
tool: `${root}/tool`,
toolIds: `${root}/tool/ids`,
resource: `${root}/resource`,
worktree: `${root}/worktree`,
worktreeReset: `${root}/worktree/reset`,
} as const

export const ExperimentalApi = HttpApi.make("experimental")
.add(
HttpApiGroup.make("experimental")
.add(
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
success: ConsoleState,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.console.get",
summary: "Get active Console provider metadata",
description: "Get the active Console org name and the set of provider IDs managed by that Console org.",
}),
),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
success: ConsoleOrgList,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.console.listOrgs",
summary: "List switchable Console orgs",
description: "Get the available Console orgs across logged-in accounts, including the current active org.",
}),
),
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
payload: ConsoleSwitchPayload,
success: Schema.Boolean,
error: BadRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.console.switchOrg",
summary: "Switch active Console org",
description: "Persist a new active Console account/org selection for the current local OpenCode state.",
}),
),
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
query: ToolListQuery,
success: Schema.Array(ToolListItem),
error: BadRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "tool.list",
summary: "List tools",
description:
"Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
}),
),
HttpApiEndpoint.get("toolIds", ExperimentalPaths.toolIds, {
success: Schema.Array(Schema.String),
error: BadRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "tool.ids",
summary: "List tool IDs",
description:
"Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
}),
),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
success: Schema.Record(Schema.String, McpResource),
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.resource.list",
summary: "Get MCP resources",
description: "Get all available MCP resources from connected servers. Optionally filter by name.",
}),
),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
payload: Schema.optional(WorktreeCreatePayload),
success: WorktreeInfo,
error: BadRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.create",
summary: "Create worktree",
description: "Create a new git worktree for the current project and run any configured startup scripts.",
}),
),
HttpApiEndpoint.get("worktreeList", ExperimentalPaths.worktree, {
success: Schema.Array(WorktreeInfo),
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.list",
summary: "List worktrees",
description: "List all sandbox worktrees for the current project.",
}),
),
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
payload: WorktreeDirectoryPayload,
success: Schema.Boolean,
error: BadRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.remove",
summary: "Remove worktree",
description: "Remove a git worktree and delete its branch.",
}),
),
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
payload: WorktreeDirectoryPayload,
success: Schema.Boolean,
error: BadRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "worktree.reset",
summary: "Reset worktree",
description: "Reset a worktree branch to the primary default branch.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "experimental",
description: "HttpApi experimental JSON routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "HttpApi surface for ordinary experimental JSON routes.",
}),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
createWorktree,
getConsoleState,
listConsoleOrgs,
listResources,
listToolIDs,
listTools,
listWorktrees,
removeWorktree,
resetWorktree,
switchConsoleOrg,
} from "@/server/instance/experimental"
import { NamedError } from "@opencode-ai/util/error"
import { Effect } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import z from "zod"
import { ExperimentalApi } from "../groups/experimental"

const ConsoleSwitchBody = z.object({
accountID: z.string(),
orgID: z.string(),
})

const WorktreeCreateBody = z
.object({
name: z.string().optional(),
startCommand: z.string().optional(),
})
.optional()
const WorktreeDirectoryBody = z.object({
directory: z.string(),
})

function isJsonContentType(contentType: string | undefined) {
// Mirrors hono/validator's jsonRegex, reached through hono-openapi's validator("json").
return /^application\/([a-z-.]+\+)?json(?:;\s*[a-zA-Z0-9-]+=([^;]+))*$/.test(contentType ?? "")
}

function badRequestJson(body: unknown) {
return HttpServerResponse.jsonUnsafe(body, { status: 400 })
}

function parseJsonBody<T>(request: HttpServerRequest.HttpServerRequest, schema: z.ZodType<T>) {
return Effect.gen(function* () {
const body = isJsonContentType(request.headers["content-type"])
? yield* request.json.pipe(
Effect.catch(() => Effect.succeed(HttpServerResponse.raw("Malformed JSON in request body", { status: 400 }))),
)
: {}
if (HttpServerResponse.isHttpServerResponse(body)) return body

const parsed = schema.safeParse(body)
if (!parsed.success) return badRequestJson({ data: body, error: parsed.error.issues, success: false })
return parsed.data
})
}

function experimentalFailure(error: unknown) {
if (error instanceof NamedError) {
const status = error.name.startsWith("Worktree") ? 400 : 500
return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status }))
}
return Effect.succeed(
HttpServerResponse.jsonUnsafe(
new NamedError.Unknown({ message: "Unexpected server error. Check server logs for details." }).toObject(),
{ status: 500 },
),
)
}

function jsonResponse<A>(effect: Effect.Effect<A, unknown, unknown>) {
return effect.pipe(
Effect.map((value) => HttpServerResponse.jsonUnsafe(value)),
Effect.catch(experimentalFailure),
Effect.catchDefect(experimentalFailure),
)
}

export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
handlers
.handleRaw("console", () => jsonResponse(getConsoleState()))
.handleRaw("consoleOrgs", () => jsonResponse(listConsoleOrgs()))
.handleRaw("consoleSwitch", (ctx) =>
Effect.gen(function* () {
const payload = yield* parseJsonBody(ctx.request, ConsoleSwitchBody)
if (HttpServerResponse.isHttpServerResponse(payload)) return payload
return yield* jsonResponse(switchConsoleOrg(payload))
}),
)
.handleRaw("tool", (ctx) =>
jsonResponse(
listTools({
provider: ctx.query.provider,
model: ctx.query.model,
}),
),
)
.handleRaw("toolIds", () => jsonResponse(listToolIDs()))
.handleRaw("resource", () => jsonResponse(listResources()))
.handleRaw("worktreeCreate", (ctx) =>
Effect.gen(function* () {
const payload = yield* parseJsonBody(ctx.request, WorktreeCreateBody)
if (HttpServerResponse.isHttpServerResponse(payload)) return payload
return yield* jsonResponse(createWorktree(payload))
}),
)
.handleRaw("worktreeList", () => jsonResponse(listWorktrees()))
.handleRaw("worktreeRemove", (ctx) =>
Effect.gen(function* () {
const payload = yield* parseJsonBody(ctx.request, WorktreeDirectoryBody)
if (HttpServerResponse.isHttpServerResponse(payload)) return payload
return yield* jsonResponse(removeWorktree(payload))
}),
)
.handleRaw("worktreeReset", (ctx) =>
Effect.gen(function* () {
const payload = yield* parseJsonBody(ctx.request, WorktreeDirectoryBody)
if (HttpServerResponse.isHttpServerResponse(payload)) return payload
return yield* jsonResponse(resetWorktree(payload))
}),
),
)
Loading