feat(core): expand v2 config schema

This commit is contained in:
Dax Raad 2026-05-27 23:11:56 -04:00
commit c925ba2b68
13 changed files with 251 additions and 97 deletions

View file

@ -3,18 +3,74 @@ export * as Config from "./config"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { AppFileSystem } from "../filesystem"
import { Global } from "../global"
import { Location } from "../location"
import { Policy } from "../policy"
import { AbsolutePath } from "../schema"
import { ConfigV2 } from "./schema"
import { AppFileSystem } from "./filesystem"
import { Global } from "./global"
import { Location } from "./location"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema"
import { ConfigExperimental } from "./config/experimental"
import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigWatcher } from "./config/watcher"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: Schema.String.pipe(Schema.optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")]).pipe(Schema.optional).annotate({
description: "Automatically update or notify when a new version is available",
}),
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
description: "Filesystem watcher configuration",
}),
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(Schema.optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered external plugin packages to load",
}),
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}
export class FileSource extends Schema.Class<FileSource>("Config.FileSource")({
type: Schema.Literal("file"),
path: Schema.String,
}) {}
export class MemorySource extends Schema.Class<MemorySource>("Config.MemorySource")({
type: Schema.Literal("memory"),
}) {}
export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type"))
export type Source = typeof Source.Type
export class Loaded extends Schema.Class<Loaded>("Config.Loaded")({
source: Source,
info: Info,
}) {}
export interface Interface {
/** Returns supplemental config directories from lowest to highest priority. */
readonly directories: () => Effect.Effect<AbsolutePath[]>
/** Loads location config files from lowest to highest priority. */
readonly get: () => Effect.Effect<ConfigV2.Loaded[]>
readonly get: () => Effect.Effect<Loaded[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
@ -39,15 +95,15 @@ export const layer = Layer.effect(
// Accept legacy fields while v2 is migrated incrementally; recognized
// fields still have to satisfy the v2 schema.
const info = Option.getOrUndefined(
Schema.decodeUnknownOption(ConfigV2.Info)(input, { errors: "all", onExcessProperty: "ignore" }),
Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }),
)
if (!info) return
return new ConfigV2.Loaded({ source: new ConfigV2.FileSource({ type: "file", path: filepath }), info })
return new Loaded({ source: new FileSource({ type: "file", path: filepath }), info })
})
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is ConfigV2.Loaded => config !== undefined)),
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
)
})
@ -74,7 +130,7 @@ export const layer = Layer.effect(
.pipe(Effect.orDie)).toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is ConfigV2.Loaded => config !== undefined)),
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
// Apply general settings first and more specific settings last:

View file

@ -0,0 +1,18 @@
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { Catalog } from "../catalog"
import { Policy as PolicyV2 } from "../policy"
// Each core domain exports the policy actions it supports. Adding an action to
// this union makes it valid in authored config while keeping Policy generic.
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
export class Policy extends Schema.Class<Policy>("Config.Experimental.Policy")({
...PolicyV2.Info.fields,
action: PolicyAction,
}) {}
export class Experimental extends Schema.Class<Experimental>("Config.Experimental")({
policies: Policy.pipe(Schema.Array, Schema.optional),
}) {}

View file

@ -0,0 +1,13 @@
export * as ConfigPlugin from "./plugin"
import { Schema } from "effect"
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
package: Schema.String,
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
export const Plugin = Schema.Union([Schema.String, Entry])
export type Plugin = typeof Plugin.Type
export const Plugins = Plugin.pipe(Schema.Array)

View file

@ -2,7 +2,7 @@ export * as ConfigProvider from "./provider"
import { Effect, Schema } from "effect"
import { Catalog } from "../catalog"
import { Config } from "./config"
import { Config } from "../config"
import { ProviderV2 } from "../provider"
import { ModelV2 } from "../model"
import { PluginV2 } from "../plugin"

View file

@ -0,0 +1,17 @@
export * as ConfigReference from "./reference"
import { Schema } from "effect"
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
}) {}
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
path: Schema.String,
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])
export type Entry = typeof Entry.Type
export const Info = Schema.Record(Schema.String, Entry)

View file

@ -1,47 +0,0 @@
export * as ConfigV2 from "./schema"
import { Schema } from "effect"
import { Catalog } from "../catalog"
import { Policy as PolicyV2 } from "../policy"
import { ConfigProvider } from "./provider"
// Each core domain exports the policy actions it supports. Adding an action to
// this union makes it valid in authored config while keeping Policy generic.
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
export class Policy extends Schema.Class<Policy>("ConfigV2.Policy")({
...PolicyV2.Info.fields,
action: PolicyAction,
}) {}
export class Experimental extends Schema.Class<Experimental>("ConfigV2.Experimental")({
policies: Policy.pipe(Schema.Array, Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("ConfigV2.Info")({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(Schema.optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
experimental: Experimental.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}
export class FileSource extends Schema.Class<FileSource>("ConfigV2.FileSource")({
type: Schema.Literal("file"),
path: Schema.String,
}) {}
export class MemorySource extends Schema.Class<MemorySource>("ConfigV2.MemorySource")({
type: Schema.Literal("memory"),
}) {}
export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type"))
export type Source = typeof Source.Type
export class Loaded extends Schema.Class<Loaded>("ConfigV2.Loaded")({
source: Source,
info: Info,
}) {}

View file

@ -0,0 +1,7 @@
export * as ConfigWatcher from "./watcher"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("Config.Watcher")({
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}

View file

@ -3,7 +3,7 @@ import { Location } from "./location"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
import { Policy } from "./policy"
import { Config } from "./config/config"
import { Config } from "./config"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {

View file

@ -3,7 +3,7 @@ export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { AccountV2 } from "../account"
import { Catalog } from "../catalog"
import { Config } from "../config/config"
import { Config } from "../config"
import { ConfigProvider } from "../config/provider"
import { EventV2 } from "../event"
import { Npm } from "../npm"