config.ts 9.4 KB

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