config.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. export * as Config from "./config"
  2. import path from "path"
  3. import { type ParseError, parse } from "jsonc-parser"
  4. import { Context, Effect, Layer, Option, Schema } from "effect"
  5. import { AppFileSystem } from "./filesystem"
  6. import { Global } from "./global"
  7. import { Location } from "./location"
  8. import { PermissionV2 } from "./permission"
  9. import { Policy } from "./policy"
  10. import { AbsolutePath } from "./schema"
  11. import { ConfigAgent } from "./config/agent"
  12. import { ConfigAttachments } from "./config/attachments"
  13. import { ConfigCompaction } from "./config/compaction"
  14. import { ConfigExperimental } from "./config/experimental"
  15. import { ConfigFormatter } from "./config/formatter"
  16. import { ConfigLSP } from "./config/lsp"
  17. import { ConfigMCP } from "./config/mcp"
  18. import { ConfigPlugin } from "./config/plugin"
  19. import { ConfigProvider } from "./config/provider"
  20. import { ConfigReference } from "./config/reference"
  21. import { ConfigToolOutput } from "./config/tool-output"
  22. import { ConfigWatcher } from "./config/watcher"
  23. export class Info extends Schema.Class<Info>("Config.Info")({
  24. $schema: Schema.optional(Schema.String).annotate({
  25. description: "JSON schema reference for configuration validation",
  26. }),
  27. shell: Schema.String.pipe(Schema.optional).annotate({
  28. description: "Default shell to use for terminal and shell tool execution",
  29. }),
  30. model: Schema.String.pipe(Schema.optional).annotate({
  31. description: "Default model to use when no session or agent model is selected",
  32. }),
  33. autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
  34. .pipe(Schema.optional)
  35. .annotate({
  36. description: "Automatically update or notify when a new version is available",
  37. }),
  38. share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
  39. description: "Control whether sessions may be shared manually, automatically, or not at all",
  40. }),
  41. enterprise: Schema.Struct({
  42. url: Schema.String.pipe(Schema.optional),
  43. })
  44. .pipe(Schema.optional)
  45. .annotate({
  46. description: "Enterprise sharing service configuration",
  47. }),
  48. username: Schema.String.pipe(Schema.optional).annotate({
  49. description: "Username displayed in conversations and used for telemetry identity",
  50. }),
  51. permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({
  52. description: "Ordered tool permission rules applied to agent tool use",
  53. }),
  54. agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
  55. description: "Named built-in agent overrides and custom agent definitions",
  56. }),
  57. snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
  58. description: "Enable snapshots used for undo and revert behavior",
  59. }),
  60. watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
  61. description: "Filesystem watcher configuration",
  62. }),
  63. formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
  64. description: "Enable built-in formatters or configure formatter overrides",
  65. }),
  66. lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
  67. description: "Enable built-in language servers or configure server overrides",
  68. }),
  69. attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
  70. description: "Attachment processing configuration",
  71. }),
  72. tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
  73. description: "Tool output truncation thresholds",
  74. }),
  75. mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
  76. description: "MCP server configuration",
  77. }),
  78. compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
  79. description: "Conversation compaction behavior",
  80. }),
  81. skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
  82. description: "Additional paths or URLs to discover skills from",
  83. }),
  84. instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
  85. description: "Additional paths or URLs supplying ambient instructions",
  86. }),
  87. references: ConfigReference.Info.pipe(Schema.optional).annotate({
  88. description: "Named local directories or Git repositories available as external context",
  89. }),
  90. plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
  91. description: "Ordered external plugin packages to load",
  92. }),
  93. experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
  94. providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
  95. }) {}
  96. export const FileSource = Schema.Struct({
  97. type: Schema.Literal("file"),
  98. path: Schema.String,
  99. }).annotate({ identifier: "Config.FileSource" })
  100. export type FileSource = typeof FileSource.Type
  101. export const MemorySource = Schema.Struct({
  102. type: Schema.Literal("memory"),
  103. }).annotate({ identifier: "Config.MemorySource" })
  104. export type MemorySource = typeof MemorySource.Type
  105. export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type"))
  106. export type Source = typeof Source.Type
  107. export class Loaded extends Schema.Class<Loaded>("Config.Loaded")({
  108. source: Source,
  109. info: Info,
  110. }) {}
  111. export interface Interface {
  112. /** Returns supplemental config directories from lowest to highest priority. */
  113. readonly directories: () => Effect.Effect<AbsolutePath[]>
  114. /** Loads location config files from lowest to highest priority. */
  115. readonly get: () => Effect.Effect<Loaded[]>
  116. }
  117. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
  118. export const layer = Layer.effect(
  119. Service,
  120. Effect.gen(function* () {
  121. const fs = yield* AppFileSystem.Service
  122. const global = yield* Global.Service
  123. const location = yield* Location.Service
  124. const policy = yield* Policy.Service
  125. const names = ["config.json", "opencode.json", "opencode.jsonc"]
  126. const loadFile = Effect.fnUntraced(function* (filepath: string) {
  127. const text = yield* fs.readFileStringSafe(filepath)
  128. if (!text) return
  129. const errors: ParseError[] = []
  130. const input: unknown = parse(text, errors, { allowTrailingComma: true })
  131. if (errors.length) return
  132. // Accept legacy fields while v2 is migrated incrementally; recognized
  133. // fields still have to satisfy the v2 schema.
  134. const info = Option.getOrUndefined(
  135. Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }),
  136. )
  137. if (!info) return
  138. return new Loaded({ source: { type: "file", path: filepath }, info })
  139. })
  140. const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
  141. return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
  142. Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
  143. )
  144. })
  145. const globalDirectory = AbsolutePath.make(global.config)
  146. const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
  147. // Read configuration once when this location opens. Later calls reuse these
  148. // values until the location is reopened.
  149. const directories = locationIsGlobal
  150. ? [globalDirectory]
  151. : [
  152. globalDirectory,
  153. ...(yield* fs
  154. .up({ targets: [".opencode"], start: location.directory, stop: location.project.directory })
  155. .pipe(Effect.orDie))
  156. .toReversed()
  157. .map((directory) => AbsolutePath.make(directory)),
  158. ]
  159. // A config closer to the opened directory should win over one higher up.
  160. // Search starts nearby, so reverse the results before applying them.
  161. const directPaths = locationIsGlobal
  162. ? []
  163. : (yield* fs
  164. .up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory })
  165. .pipe(Effect.orDie)).toReversed()
  166. const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
  167. Effect.orDie,
  168. Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
  169. )
  170. const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
  171. // Apply general settings first and more specific settings last:
  172. // global config, project files, then `.opencode` files.
  173. const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
  174. // Rules use the opposite order so a user-global rule can override a
  175. // repository rule. Statement order inside each file stays unchanged.
  176. yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? []))
  177. return Service.of({
  178. directories: Effect.fn("Config.directories")(function* () {
  179. return directories
  180. }),
  181. get: Effect.fn("Config.get")(function* () {
  182. return configs
  183. }),
  184. })
  185. }),
  186. )
  187. export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))