feat(cli): publish generated CLI configuration schema (#45070)

Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-08-25 22:49:45 +02:00
committed by GitHub
parent db7837814c
commit d4803ffe38
10 changed files with 1489 additions and 13 deletions
+27
View File
@@ -0,0 +1,27 @@
import { Schema } from "effect"
import { format } from "prettier"
import { Info, SchemaURL } from "../src/config/schema"
const target = process.argv[2]
if (!target) throw new Error("A schema output path is required")
const document = Schema.toJsonSchemaDocument(Info)
const content = await format(
JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: SchemaURL,
...document.schema,
...(Object.keys(document.definitions).length ? { $defs: document.definitions } : {}),
}),
{ parser: "json", printWidth: 120 },
)
if (process.argv.includes("--check")) {
if ((await Bun.file(target).text()) !== content) {
console.error("Generated CLI config schema is stale. Run `bun run generate` from packages/www.")
process.exit(1)
}
process.exit(0)
}
await Bun.write(target, content)
+4 -2
View File
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { Info } from "./schema"
import { Info, SchemaURL } from "./schema"
export * from "./schema"
@@ -87,7 +87,9 @@ export const layer = Layer.effect(
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "{}"))
const text = yield* fs
.readFileString(file)
.pipe(Effect.orElseSucceed(() => JSON.stringify({ $schema: SchemaURL }, null, 2)))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
+4 -3
View File
@@ -7,7 +7,7 @@ import { Effect, FileSystem, Option, Schema } from "effect"
import { randomUUID } from "crypto"
import { applyEdits, createScanner, modify, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
import { Info } from "./schema"
import { Info, SchemaURL } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
@@ -100,8 +100,9 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
const kv = yield* readJson(path.join(input.state, "kv.json"))
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const values = migrateV1(legacy, kv ?? {})
if (!Object.keys(values).length) return
const migrated = { $schema: SchemaURL, ...values }
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
if (result.cause === undefined)
+6 -1
View File
@@ -1,5 +1,10 @@
import { Config } from "@opencode-ai/tui/config"
import { Schema } from "effect"
export const Info = Schema.Struct({ ...Config.Info.fields })
export const SchemaURL = "https://opencode.ai/v2/cli.json"
export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({ description: "JSON Schema for CLI configuration" }),
...Config.Info.fields,
})
export type Info = Schema.Schema.Type<typeof Info>
+70 -2
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Option } from "effect"
import { Effect, FileSystem, Option, Schema } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
@@ -17,6 +17,67 @@ function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service
)
}
test("generates reusable keybind schemas and preserves descriptions and numeric constraints", () => {
const document = Schema.toJsonSchemaDocument(Config.Info)
expect(document).toHaveProperty(["definitions", "TuiKeybind.BindingValue"])
expect(document).toHaveProperty(["schema", "properties", "keybinds", "anyOf", 0, "properties", "app.exit"], {
anyOf: [{ $ref: "#/$defs/TuiKeybind.BindingValue" }, { type: "null" }],
description: "Exit the application",
})
expect(document).toHaveProperty(["schema", "properties", "scroll", "anyOf", 0, "properties", "speed", "anyOf", 0], {
type: "number",
minimum: 0.001,
})
expect(document).toHaveProperty(
["schema", "properties", "attention", "anyOf", 0, "properties", "volume", "anyOf", 0],
{ type: "number", minimum: 0, maximum: 1 },
)
})
test("includes the published schema when creating cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the schema in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, JSON.stringify({ $schema: "https://opencode.ai/v2/cli.json", animations: true }))
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(file).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates tui and kv config into cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(
@@ -73,6 +134,7 @@ test("migrates tui and kv config into cli.json", async () => {
)
expect(config).toMatchObject({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy", mode: "light" },
keybinds: {
leader: "ctrl+o",
@@ -128,8 +190,14 @@ test("migrates before the first update and does not remigrate afterward", async
}),
)
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
expect(config).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
})
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
+2 -2
View File
@@ -66,7 +66,7 @@ export const Info = Schema.Struct({
).annotate({ description: "Leader key behavior" }),
scroll: Schema.optional(
Schema.Struct({
speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))).annotate({
speed: Schema.optional(Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0.001))).annotate({
description: "Distance scrolled per input tick",
}),
acceleration: Schema.optional(Schema.Boolean).annotate({
@@ -80,7 +80,7 @@ export const Info = Schema.Struct({
notifications: Schema.optional(Schema.Boolean).annotate({ description: "Show system notifications" }),
sound: Schema.optional(Schema.Boolean).annotate({ description: "Play attention sounds" }),
volume: Schema.optional(
Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1)),
Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1)),
).annotate({ description: "Attention sound volume from 0 to 1" }),
sound_pack: Schema.optional(Schema.String).annotate({ description: "Active attention sound pack ID" }),
sounds: Schema.optional(Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String))).annotate({
+1 -1
View File
@@ -30,7 +30,7 @@ export const BindingValueSchema = Schema.Union([
Schema.Literal("none"),
BindingItem,
Schema.Array(BindingItem),
])
]).annotate({ identifier: "TuiKeybind.BindingValue" })
export type BindingValueSchema = Schema.Schema.Type<typeof BindingValueSchema>
type Definition = {
+2 -2
View File
@@ -7,8 +7,8 @@
"dev": "bun run generate && astro dev --host --port 3000",
"build": "bun run generate && astro build && bun script/pagefind.ts && bun run validate && bun script/prepare-cloudflare.ts",
"deploy": "wrangler deploy --config dist/server/wrangler.json",
"generate": "bun script/generate-openapi.ts",
"check:generated": "bun script/generate-openapi.ts --check",
"generate": "bun script/generate-openapi.ts && bun --cwd ../cli script/schema.ts ../www/public/cli.json",
"check:generated": "bun script/generate-openapi.ts --check && bun --cwd ../cli script/schema.ts ../www/public/cli.json --check",
"validate": "bun script/validate-links.ts",
"typecheck": "astro check"
},
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,16 @@ Set `$XDG_CONFIG_HOME` to use a different config directory:
$XDG_CONFIG_HOME/opencode/cli.json
```
Add the published JSON Schema for editor validation and autocomplete:
```json title="cli.json"
{
"$schema": "https://opencode.ai/v2/cli.json"
}
```
OpenCode includes this automatically when it creates or migrates your CLI config.
## Theme
Set the theme and color mode: