diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 93ef81a32524..d882857ba10a 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -224,7 +224,7 @@ When to use each: Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape. -Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior: +Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those — and for pure-object schemas where handlers populate plain objects rather than class instances — add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior without the `instanceof` requirement: ```ts export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" }) @@ -373,9 +373,9 @@ The first slice is successful if: - `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`. - scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes. -- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. +- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. `Schema.Class`'s Declaration AST enforces `input instanceof self || input.[ClassTypeId]` during encode (see effect-smol `Schema.ts:10479-10484`). Plain objects from zod parse fail with `Expected Foo, got {...}`. This surfaced on `GET /config` where the service returns zod-parsed plain objects and `Config.InfoSchema` referenced `ConfigProvider.Info` (class). The fix was to convert pure-object classes to `Schema.Struct(...).annotate({ identifier: "..." })` — same named SDK `$ref`, no instance requirement. Verified byte-identical `types.gen.ts` vs `dev`. - internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes. -- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes. +- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema **and** when the handler/service returns real instances. For schemas that need a named `$ref` but are populated from plain objects, use `Schema.Struct(...).annotate({ identifier: "..." })` instead. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes. ### Integration @@ -404,8 +404,7 @@ Current instance route inventory: - `provider` - `bridged` endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback` - `config` - `bridged` (partial) - bridged endpoint: `GET /config/providers` - later endpoint: `GET /config` + bridged endpoints: `GET /config`, `GET /config/providers` defer `PATCH /config` for now - `project` - `bridged` (partial) bridged endpoints: `GET /project`, `GET /project/current` @@ -431,9 +430,8 @@ Current instance route inventory: Recommended near-term sequence: 1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`) -2. `config` full read endpoint (`GET /config`) -3. `file` JSON read endpoints -4. `mcp` JSON read endpoints +2. `file` JSON read endpoints +3. `mcp` JSON read endpoints ## Checklist @@ -449,8 +447,8 @@ Recommended near-term sequence: - [x] port remaining provider endpoints (`GET /provider`, OAuth mutations) - [x] port `config` providers read endpoint - [x] port `project` read endpoints (`GET /project`, `GET /project/current`) +- [x] port `GET /config` full read endpoint - [ ] port `workspace` read endpoints -- [ ] port `GET /config` full read endpoint - [ ] port `file` JSON read endpoints - [ ] decide when to remove the flag and make Effect routes the default diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 1469522d9812..d972d622feca 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -101,7 +101,8 @@ const normalize = (agent: z.infer) => { } globalThis.Object.assign(permission, agent.permission) - return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps } + const steps = agent.steps ?? agent.maxSteps + return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) } } export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType< diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 55684fc70dfb..336de64b9de1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -91,7 +91,7 @@ const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level }) const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) -const InfoSchema = Schema.Struct({ +export const InfoSchema = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation", }), diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 8b77bc4c286d..0887fa984ab7 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -2,7 +2,7 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -export class Local extends Schema.Class("McpLocalConfig")({ +export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), command: Schema.mutable(Schema.Array(Schema.String)).annotate({ description: "Command and arguments to run the MCP server", @@ -16,11 +16,12 @@ export class Local extends Schema.Class("McpLocalConfig")({ timeout: Schema.optional(Schema.Number).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "McpLocalConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Local = Schema.Schema.Type -export class OAuth extends Schema.Class("McpOAuthConfig")({ +export const OAuth = Schema.Struct({ clientId: Schema.optional(Schema.String).annotate({ description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", }), @@ -31,11 +32,12 @@ export class OAuth extends Schema.Class("McpOAuthConfig")({ redirectUri: Schema.optional(Schema.String).annotate({ description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "McpOAuthConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type OAuth = Schema.Schema.Type -export class Remote extends Schema.Class("McpRemoteConfig")({ +export const Remote = Schema.Struct({ type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }), url: Schema.String.annotate({ description: "URL of the remote MCP server" }), enabled: Schema.optional(Schema.Boolean).annotate({ @@ -50,9 +52,10 @@ export class Remote extends Schema.Class("McpRemoteConfig")({ timeout: Schema.optional(Schema.Number).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "McpRemoteConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Remote = Schema.Schema.Type export const Info = Schema.Union([Local, Remote]) .annotate({ discriminator: "type" }) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 212e716251fd..bd6ae35996bd 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -70,7 +70,7 @@ export const Model = Schema.Struct({ ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) -export class Info extends Schema.Class("ProviderConfig")({ +export const Info = Schema.Struct({ api: Schema.optional(Schema.String), name: Schema.optional(Schema.String), env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), @@ -107,8 +107,9 @@ export class Info extends Schema.Class("ProviderConfig")({ ), ), models: Schema.optional(Schema.Record(Schema.String, Model)), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "ProviderConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type export * as ConfigProvider from "./provider" diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 969a79964b37..3ce4fe626264 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,7 +1,8 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export class Server extends Schema.Class("ServerConfig")({ +export const Server = Schema.Struct({ port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({ description: "Port to listen on", }), @@ -13,8 +14,9 @@ export class Server extends Schema.Class("ServerConfig")({ cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional domains to allow for CORS", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "ServerConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Server = Schema.Schema.Type export * as ConfigServer from "./server" diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts index 14aa94f9fcf0..678e96e33f58 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts @@ -9,6 +9,15 @@ export const ConfigApi = HttpApi.make("config") .add( HttpApiGroup.make("config") .add( + HttpApiEndpoint.get("get", root, { + success: Config.InfoSchema, + }).annotateMerge( + OpenApi.annotations({ + identifier: "config.get", + summary: "Get configuration", + description: "Retrieve the current OpenCode configuration settings and preferences.", + }), + ), HttpApiEndpoint.get("providers", `${root}/providers`, { success: Provider.ConfigProvidersResult, }).annotateMerge( @@ -36,16 +45,23 @@ export const ConfigApi = HttpApi.make("config") export const configHandlers = Layer.unwrap( Effect.gen(function* () { - const svc = yield* Provider.Service + const providerSvc = yield* Provider.Service + const configSvc = yield* Config.Service + + const get = Effect.fn("ConfigHttpApi.get")(function* () { + return yield* configSvc.get() + }) const providers = Effect.fn("ConfigHttpApi.providers")(function* () { - const providers = yield* svc.list() + const providers = yield* providerSvc.list() return { providers: Object.values(providers), default: Provider.defaultModelIDs(providers), } }) - return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers)) + return HttpApiBuilder.group(ConfigApi, "config", (handlers) => + handlers.handle("get", get).handle("providers", providers), + ) }), ).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 5cc51d27abb5..0038c596199c 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -40,6 +40,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) app.get("/permission", (c) => handler(c.req.raw, context)) app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config", (c) => handler(c.req.raw, context)) app.get("/config/providers", (c) => handler(c.req.raw, context)) app.get("/provider", (c) => handler(c.req.raw, context)) app.get("/provider/auth", (c) => handler(c.req.raw, context))