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
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: ConfigPlugin.Spec.array().optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export const Info = z
"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.",
),
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
plugin: ConfigPlugin.Spec.array().optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
Expand Down
15 changes: 10 additions & 5 deletions packages/opencode/src/config/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { Glob } from "@opencode-ai/shared/util/glob"
import z from "zod"
import { Schema } from "effect"
import { pathToFileURL } from "url"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import path from "path"

const Options = z.record(z.string(), z.unknown())
export type Options = z.infer<typeof Options>
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Options = Schema.Schema.Type<typeof Options>

// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
// It answers "what should we load?" but says nothing about where that value came from.
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
export type Spec = z.infer<typeof Spec>
export const Spec = Schema.Union([
Schema.String,
Schema.mutable(Schema.Tuple([Schema.String, Options])),
]).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Spec = Schema.Schema.Type<typeof Spec>

export type Scope = "global" | "local"

Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/src/util/effect-zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,16 @@ function object(ast: SchemaAST.Objects): z.ZodTypeAny {
}

function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
if (ast.elements.length > 0) return fail(ast)
if (ast.rest.length !== 1) return fail(ast)
return z.array(walk(ast.rest[0]))
// Pure variadic arrays: { elements: [], rest: [item] }
if (ast.elements.length === 0) {
if (ast.rest.length !== 1) return fail(ast)
return z.array(walk(ast.rest[0]))
}
// Fixed-length tuples: { elements: [a, b, ...], rest: [] }
// Tuples with a variadic tail (...rest) are not yet supported.
if (ast.rest.length > 0) return fail(ast)
const items = ast.elements.map(walk)
return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
}

function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
Expand Down
28 changes: 26 additions & 2 deletions packages/opencode/test/util/effect-zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,32 @@ describe("util.effect-zod", () => {
})
})

test("throws for unsupported tuple schemas", () => {
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
describe("Tuples", () => {
test("fixed-length tuple parses matching array", () => {
const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
expect(out.parse(["a", 1])).toEqual(["a", 1])
expect(out.safeParse(["a"]).success).toBe(false)
expect(out.safeParse(["a", "b"]).success).toBe(false)
})

test("single-element tuple parses a one-element array", () => {
const out = zod(Schema.Tuple([Schema.Boolean]))
expect(out.parse([true])).toEqual([true])
expect(out.safeParse([true, false]).success).toBe(false)
})

test("tuple inside a union picks the right branch", () => {
const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
expect(out.parse("hello")).toBe("hello")
expect(out.parse(["foo", 42])).toEqual(["foo", 42])
expect(out.safeParse(["foo"]).success).toBe(false)
})

test("plain arrays still work (no element positions)", () => {
const out = zod(Schema.Array(Schema.String))
expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
expect(out.parse([])).toEqual([])
})
})

test("string literal unions produce z.enum with enum in JSON Schema", () => {
Expand Down
Loading