| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- 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<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",
- }),
- 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<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<Loaded[]>
- }
- export class Service extends Context.Service<Service, Interface>()("@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))
|