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 { PermissionV2 } from "./permission" import { Policy } from "./policy" import { AbsolutePath } from "./schema" import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" import { ConfigMCP } from "./config/mcp" import { ConfigPlugin } from "./config/plugin" import { ConfigProvider } from "./config/provider" import { ConfigReference } from "./config/reference" import { ConfigToolOutput } from "./config/tool-output" import { ConfigWatcher } from "./config/watcher" export class Info extends Schema.Class("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", }), share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({ description: "Control whether sessions may be shared manually, automatically, or not at all", }), enterprise: Schema.Struct({ url: Schema.String.pipe(Schema.optional), }) .pipe(Schema.optional) .annotate({ description: "Enterprise sharing service configuration", }), username: Schema.String.pipe(Schema.optional).annotate({ description: "Username displayed in conversations and used for telemetry identity", }), permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({ description: "Ordered tool permission rules applied to agent tool use", }), agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({ description: "Named built-in agent overrides and custom agent definitions", }), 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", }), formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({ description: "Enable built-in formatters or configure formatter overrides", }), lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({ description: "Enable built-in language servers or configure server overrides", }), attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({ description: "Attachment processing configuration", }), tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({ description: "Tool output truncation thresholds", }), mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({ description: "MCP server configuration", }), compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({ description: "Conversation compaction behavior", }), 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 const FileSource = Schema.Struct({ type: Schema.Literal("file"), path: Schema.String, }).annotate({ identifier: "Config.FileSource" }) export type FileSource = typeof FileSource.Type export const MemorySource = Schema.Struct({ type: Schema.Literal("memory"), }).annotate({ identifier: "Config.MemorySource" }) export type MemorySource = typeof MemorySource.Type export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type")) export type Source = typeof Source.Type export class Loaded extends Schema.Class("Config.Loaded")({ source: Source, info: Info, }) {} export interface Interface { /** Returns supplemental config directories from lowest to highest priority. */ readonly directories: () => Effect.Effect /** Loads location config files from lowest to highest priority. */ readonly get: () => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Config") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service const global = yield* Global.Service const location = yield* Location.Service const policy = yield* Policy.Service const names = ["config.json", "opencode.json", "opencode.jsonc"] const loadFile = Effect.fnUntraced(function* (filepath: string) { const text = yield* fs.readFileStringSafe(filepath) if (!text) return const errors: ParseError[] = [] const input: unknown = parse(text, errors, { allowTrailingComma: true }) if (errors.length) return // Accept legacy fields while v2 is migrated incrementally; recognized // fields still have to satisfy the v2 schema. const info = Option.getOrUndefined( Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }), ) if (!info) return return new Loaded({ source: { 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 Loaded => config !== undefined)), ) }) const globalDirectory = AbsolutePath.make(global.config) const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) // Read configuration once when this location opens. Later calls reuse these // values until the location is reopened. const directories = locationIsGlobal ? [globalDirectory] : [ globalDirectory, ...(yield* fs .up({ targets: [".opencode"], start: location.directory, stop: location.project.directory }) .pipe(Effect.orDie)) .toReversed() .map((directory) => AbsolutePath.make(directory)), ] // A config closer to the opened directory should win over one higher up. // Search starts nearby, so reverse the results before applying them. const directPaths = locationIsGlobal ? [] : (yield* fs .up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory }) .pipe(Effect.orDie)).toReversed() const direct = yield* Effect.forEach(directPaths, loadFile).pipe( Effect.orDie, 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: // global config, project files, then `.opencode` files. const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] // Rules use the opposite order so a user-global rule can override a // repository rule. Statement order inside each file stays unchanged. yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? [])) return Service.of({ directories: Effect.fn("Config.directories")(function* () { return directories }), get: Effect.fn("Config.get")(function* () { return configs }), }) }), ) export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))