@@ -25,6 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e
2525import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
2626import { InstanceRef } from "@/effect/instance-ref"
2727import { zod , ZodOverride } from "@/util/effect-zod"
28+ import { withStatics } from "@/util/schema"
2829import { ConfigAgent } from "./agent"
2930import { ConfigCommand } from "./command"
3031import { ConfigFormatter } from "./formatter"
@@ -91,7 +92,15 @@ const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
9192const PositiveInt = Schema . Number . check ( Schema . isInt ( ) ) . check ( Schema . isGreaterThan ( 0 ) )
9293const NonNegativeInt = Schema . Number . check ( Schema . isInt ( ) ) . check ( Schema . isGreaterThanOrEqualTo ( 0 ) )
9394
94- export const InfoSchema = Schema . Struct ( {
95+ // The Effect Schema is the canonical source of truth. The `.zod` compatibility
96+ // surface is derived so existing Hono validators keep working without a parallel
97+ // Zod definition.
98+ //
99+ // The walker emits `z.object({...})` which is non-strict by default. Config
100+ // historically uses `.strict()` (additionalProperties: false in openapi.json),
101+ // so layer that on after derivation. Re-apply the Config ref afterward
102+ // since `.strict()` strips the walker's meta annotation.
103+ export const Info = Schema . Struct ( {
95104 $schema : Schema . optional ( Schema . String ) . annotate ( {
96105 description : "JSON schema reference for configuration validation" ,
97106 } ) ,
@@ -235,6 +244,14 @@ export const InfoSchema = Schema.Struct({
235244 } ) ,
236245 ) ,
237246} )
247+ . annotate ( { identifier : "Config" } )
248+ . pipe (
249+ withStatics ( ( s ) => ( {
250+ zod : ( zod ( s ) as unknown as z . ZodObject < any > )
251+ . strict ( )
252+ . meta ( { ref : "Config" } ) as unknown as z . ZodType < DeepMutable < Schema . Schema . Type < typeof s > > > ,
253+ } ) ) ,
254+ )
238255
239256// Schema.Struct produces readonly types by default, but the service code
240257// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
@@ -256,15 +273,7 @@ type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
256273 ? { - readonly [ K in keyof T ] : DeepMutable < T [ K ] > }
257274 : T
258275
259- // The walker emits `z.object({...})` which is non-strict by default. Config
260- // historically uses `.strict()` (additionalProperties: false in openapi.json),
261- // so layer that on after derivation. Re-apply the Config ref afterward
262- // since `.strict()` strips the walker's meta annotation.
263- export const Info = ( zod ( InfoSchema ) as unknown as z . ZodObject < any > )
264- . strict ( )
265- . meta ( { ref : "Config" } ) as unknown as z . ZodType < DeepMutable < Schema . Schema . Type < typeof InfoSchema > > >
266-
267- export type Info = z . output < typeof Info > & {
276+ export type Info = DeepMutable < Schema . Schema . Type < typeof Info > > & {
268277 // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
269278 // with the file and scope it came from so later runtime code can make location-sensitive decisions.
270279 plugin_origins ?: ConfigPlugin . Origin [ ]
@@ -361,7 +370,7 @@ export const layer = Layer.effect(
361370 ) ,
362371 )
363372 const parsed = ConfigParse . jsonc ( expanded , source )
364- const data = ConfigParse . schema ( Info , normalizeLoadedConfig ( parsed , source ) , source )
373+ const data = ConfigParse . schema ( Info . zod , normalizeLoadedConfig ( parsed , source ) , source )
365374 if ( ! ( "path" in options ) ) return data
366375
367376 yield * Effect . promise ( ( ) => resolveLoadedPlugins ( data , options . path ) )
@@ -753,13 +762,13 @@ export const layer = Layer.effect(
753762
754763 let next : Info
755764 if ( ! file . endsWith ( ".jsonc" ) ) {
756- const existing = ConfigParse . schema ( Info , ConfigParse . jsonc ( before , file ) , file )
765+ const existing = ConfigParse . schema ( Info . zod , ConfigParse . jsonc ( before , file ) , file )
757766 const merged = mergeDeep ( writable ( existing ) , writable ( config ) )
758767 yield * fs . writeFileString ( file , JSON . stringify ( merged , null , 2 ) ) . pipe ( Effect . orDie )
759768 next = merged
760769 } else {
761770 const updated = patchJsonc ( before , writable ( config ) )
762- next = ConfigParse . schema ( Info , ConfigParse . jsonc ( updated , file ) , file )
771+ next = ConfigParse . schema ( Info . zod , ConfigParse . jsonc ( updated , file ) , file )
763772 yield * fs . writeFileString ( file , updated ) . pipe ( Effect . orDie )
764773 }
765774
0 commit comments