Skip to content

Commit 23f3147

Browse files
authored
refactor(config): migrate config.ts root Info to Effect Schema (#23241)
1 parent c0eab9e commit 23f3147

1 file changed

Lines changed: 174 additions & 143 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 174 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import { isRecord } from "@/util/record"
2121
import type { ConsoleState } from "./console-state"
2222
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
2323
import { InstanceState } from "@/effect"
24-
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
24+
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
2525
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
2626
import { InstanceRef } from "@/effect/instance-ref"
27+
import { zod, ZodOverride } from "@/util/effect-zod"
2728
import { ConfigAgent } from "./agent"
2829
import { ConfigCommand } from "./command"
2930
import { ConfigFormatter } from "./formatter"
@@ -79,152 +80,182 @@ export const Server = ConfigServer.Server.zod
7980
export const Layout = ConfigLayout.Layout.zod
8081
export type Layout = ConfigLayout.Layout
8182

82-
export const Info = z
83-
.object({
84-
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
85-
logLevel: Log.Level.optional().describe("Log level"),
86-
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
87-
command: z
88-
.record(z.string(), ConfigCommand.Info.zod)
89-
.optional()
90-
.describe("Command configuration, see https://opencode.ai/docs/commands"),
91-
skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"),
92-
watcher: z
93-
.object({
94-
ignore: z.array(z.string()).optional(),
95-
})
96-
.optional(),
97-
snapshot: z
98-
.boolean()
99-
.optional()
100-
.describe(
101-
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
102-
),
103-
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
104-
plugin: ConfigPlugin.Spec.zod.array().optional(),
105-
share: z
106-
.enum(["manual", "auto", "disabled"])
107-
.optional()
108-
.describe(
109-
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
110-
),
111-
autoshare: z
112-
.boolean()
113-
.optional()
114-
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
115-
autoupdate: z
116-
.union([z.boolean(), z.literal("notify")])
117-
.optional()
118-
.describe(
119-
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
120-
),
121-
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
122-
enabled_providers: z
123-
.array(z.string())
124-
.optional()
125-
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
126-
model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
127-
small_model: ConfigModelID.zod
128-
.describe("Small model to use for tasks like title generation in the format of provider/model")
129-
.optional(),
130-
default_agent: z
131-
.string()
132-
.optional()
133-
.describe(
134-
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
135-
),
136-
username: z.string().optional().describe("Custom username to display in conversations instead of system username"),
137-
mode: z
138-
.object({
139-
build: ConfigAgent.Info.optional(),
140-
plan: ConfigAgent.Info.optional(),
141-
})
142-
.catchall(ConfigAgent.Info)
143-
.optional()
144-
.describe("@deprecated Use `agent` field instead."),
145-
agent: z
146-
.object({
83+
// Schemas that still live at the zod layer (have .transform / .preprocess /
84+
// .meta not expressible in current Effect Schema) get referenced via a
85+
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
86+
// exact zod directly, preserving component $refs.
87+
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
88+
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
89+
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
90+
91+
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
92+
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
93+
94+
const InfoSchema = Schema.Struct({
95+
$schema: Schema.optional(Schema.String).annotate({
96+
description: "JSON schema reference for configuration validation",
97+
}),
98+
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
99+
server: Schema.optional(ConfigServer.Server).annotate({
100+
description: "Server configuration for opencode serve and web commands",
101+
}),
102+
command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({
103+
description: "Command configuration, see https://opencode.ai/docs/commands",
104+
}),
105+
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
106+
watcher: Schema.optional(
107+
Schema.Struct({
108+
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
109+
}),
110+
),
111+
snapshot: Schema.optional(Schema.Boolean).annotate({
112+
description:
113+
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
114+
}),
115+
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
116+
plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))),
117+
share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({
118+
description:
119+
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
120+
}),
121+
autoshare: Schema.optional(Schema.Boolean).annotate({
122+
description: "@deprecated Use 'share' field instead. Share newly created sessions automatically",
123+
}),
124+
autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({
125+
description:
126+
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
127+
}),
128+
disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
129+
description: "Disable providers that are loaded automatically",
130+
}),
131+
enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
132+
description: "When set, ONLY these providers will be enabled. All other providers will be ignored",
133+
}),
134+
model: Schema.optional(ConfigModelID).annotate({
135+
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
136+
}),
137+
small_model: Schema.optional(ConfigModelID).annotate({
138+
description: "Small model to use for tasks like title generation in the format of provider/model",
139+
}),
140+
default_agent: Schema.optional(Schema.String).annotate({
141+
description:
142+
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
143+
}),
144+
username: Schema.optional(Schema.String).annotate({
145+
description: "Custom username to display in conversations instead of system username",
146+
}),
147+
mode: Schema.optional(
148+
Schema.StructWithRest(
149+
Schema.Struct({
150+
build: Schema.optional(AgentRef),
151+
plan: Schema.optional(AgentRef),
152+
}),
153+
[Schema.Record(Schema.String, AgentRef)],
154+
),
155+
).annotate({ description: "@deprecated Use `agent` field instead." }),
156+
agent: Schema.optional(
157+
Schema.StructWithRest(
158+
Schema.Struct({
147159
// primary
148-
plan: ConfigAgent.Info.optional(),
149-
build: ConfigAgent.Info.optional(),
160+
plan: Schema.optional(AgentRef),
161+
build: Schema.optional(AgentRef),
150162
// subagent
151-
general: ConfigAgent.Info.optional(),
152-
explore: ConfigAgent.Info.optional(),
163+
general: Schema.optional(AgentRef),
164+
explore: Schema.optional(AgentRef),
153165
// specialized
154-
title: ConfigAgent.Info.optional(),
155-
summary: ConfigAgent.Info.optional(),
156-
compaction: ConfigAgent.Info.optional(),
157-
})
158-
.catchall(ConfigAgent.Info)
159-
.optional()
160-
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
161-
provider: z
162-
.record(z.string(), ConfigProvider.Info.zod)
163-
.optional()
164-
.describe("Custom provider configurations and model overrides"),
165-
mcp: z
166-
.record(
167-
z.string(),
168-
z.union([
169-
ConfigMCP.Info.zod,
170-
z
171-
.object({
172-
enabled: z.boolean(),
173-
})
174-
.strict(),
175-
]),
176-
)
177-
.optional()
178-
.describe("MCP (Model Context Protocol) server configurations"),
179-
formatter: ConfigFormatter.Info.zod.optional(),
180-
lsp: ConfigLSP.Info.zod.optional(),
181-
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
182-
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
183-
permission: ConfigPermission.Info.optional(),
184-
tools: z.record(z.string(), z.boolean()).optional(),
185-
enterprise: z
186-
.object({
187-
url: z.string().optional().describe("Enterprise URL"),
188-
})
189-
.optional(),
190-
compaction: z
191-
.object({
192-
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
193-
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
194-
reserved: z
195-
.number()
196-
.int()
197-
.min(0)
198-
.optional()
199-
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
200-
})
201-
.optional(),
202-
experimental: z
203-
.object({
204-
disable_paste_summary: z.boolean().optional(),
205-
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
206-
openTelemetry: z
207-
.boolean()
208-
.optional()
209-
.describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"),
210-
primary_tools: z
211-
.array(z.string())
212-
.optional()
213-
.describe("Tools that should only be available to primary agents."),
214-
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
215-
mcp_timeout: z
216-
.number()
217-
.int()
218-
.positive()
219-
.optional()
220-
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
221-
})
222-
.optional(),
223-
})
166+
title: Schema.optional(AgentRef),
167+
summary: Schema.optional(AgentRef),
168+
compaction: Schema.optional(AgentRef),
169+
}),
170+
[Schema.Record(Schema.String, AgentRef)],
171+
),
172+
).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
173+
provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
174+
description: "Custom provider configurations and model overrides",
175+
}),
176+
mcp: Schema.optional(
177+
Schema.Record(
178+
Schema.String,
179+
Schema.Union([
180+
ConfigMCP.Info,
181+
// Matches the legacy `{ enabled: false }` form used to disable a server.
182+
Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }),
183+
]),
184+
),
185+
).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
186+
formatter: Schema.optional(ConfigFormatter.Info),
187+
lsp: Schema.optional(ConfigLSP.Info),
188+
instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
189+
description: "Additional instruction files or patterns to include",
190+
}),
191+
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
192+
permission: Schema.optional(PermissionRef),
193+
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
194+
enterprise: Schema.optional(
195+
Schema.Struct({
196+
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
197+
}),
198+
),
199+
compaction: Schema.optional(
200+
Schema.Struct({
201+
auto: Schema.optional(Schema.Boolean).annotate({
202+
description: "Enable automatic compaction when context is full (default: true)",
203+
}),
204+
prune: Schema.optional(Schema.Boolean).annotate({
205+
description: "Enable pruning of old tool outputs (default: true)",
206+
}),
207+
reserved: Schema.optional(NonNegativeInt).annotate({
208+
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
209+
}),
210+
}),
211+
),
212+
experimental: Schema.optional(
213+
Schema.Struct({
214+
disable_paste_summary: Schema.optional(Schema.Boolean),
215+
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
216+
openTelemetry: Schema.optional(Schema.Boolean).annotate({
217+
description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)",
218+
}),
219+
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
220+
description: "Tools that should only be available to primary agents.",
221+
}),
222+
continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
223+
description: "Continue the agent loop when a tool call is denied",
224+
}),
225+
mcp_timeout: Schema.optional(PositiveInt).annotate({
226+
description: "Timeout in milliseconds for model context protocol (MCP) requests",
227+
}),
228+
}),
229+
),
230+
})
231+
232+
// Schema.Struct produces readonly types by default, but the service code
233+
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
234+
// readonly recursively so callers get the same mutable shape zod inferred.
235+
//
236+
// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback
237+
// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}`
238+
// (since `keyof unknown = never`), which widens `Record<string, unknown>`
239+
// fields like `ConfigPlugin.Options`. The local version gates on
240+
// `extends object` so `unknown` passes through.
241+
//
242+
// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]`
243+
// shape (otherwise the general array branch widens it to an array).
244+
type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
245+
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
246+
: T extends readonly (infer U)[]
247+
? DeepMutable<U>[]
248+
: T extends object
249+
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
250+
: T
251+
252+
// The walker emits `z.object({...})` which is non-strict by default. Config
253+
// historically uses `.strict()` (additionalProperties: false in openapi.json),
254+
// so layer that on after derivation. Re-apply the Config ref afterward
255+
// since `.strict()` strips the walker's meta annotation.
256+
export const Info = (zod(InfoSchema) as unknown as z.ZodObject<any>)
224257
.strict()
225-
.meta({
226-
ref: "Config",
227-
})
258+
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>
228259

229260
export type Info = z.output<typeof Info> & {
230261
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together

0 commit comments

Comments
 (0)