config.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import { Log } from "../util/log"
  2. import path from "path"
  3. import { z } from "zod"
  4. import { App } from "../app/app"
  5. import { Filesystem } from "../util/filesystem"
  6. import { ModelsDev } from "../provider/models"
  7. import { mergeDeep, pipe } from "remeda"
  8. import { Global } from "../global"
  9. import fs from "fs/promises"
  10. import { lazy } from "../util/lazy"
  11. import { NamedError } from "../util/error"
  12. export namespace Config {
  13. const log = Log.create({ service: "config" })
  14. export const state = App.state("config", async (app) => {
  15. let result = await global()
  16. for (const file of ["opencode.jsonc", "opencode.json"]) {
  17. const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
  18. for (const resolved of found.toReversed()) {
  19. result = mergeDeep(result, await load(resolved))
  20. }
  21. }
  22. // Handle migration from autoshare to share field
  23. if (result.autoshare === true && !result.share) {
  24. result.share = "auto"
  25. }
  26. if (!result.username) {
  27. const os = await import("os")
  28. result.username = os.userInfo().username
  29. }
  30. log.info("loaded", result)
  31. return result
  32. })
  33. export const McpLocal = z
  34. .object({
  35. type: z.literal("local").describe("Type of MCP server connection"),
  36. command: z.string().array().describe("Command and arguments to run the MCP server"),
  37. environment: z
  38. .record(z.string(), z.string())
  39. .optional()
  40. .describe("Environment variables to set when running the MCP server"),
  41. enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
  42. })
  43. .strict()
  44. .openapi({
  45. ref: "McpLocalConfig",
  46. })
  47. export const McpRemote = z
  48. .object({
  49. type: z.literal("remote").describe("Type of MCP server connection"),
  50. url: z.string().describe("URL of the remote MCP server"),
  51. enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
  52. })
  53. .strict()
  54. .openapi({
  55. ref: "McpRemoteConfig",
  56. })
  57. export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
  58. export type Mcp = z.infer<typeof Mcp>
  59. export const Mode = z
  60. .object({
  61. model: z.string().optional(),
  62. prompt: z.string().optional(),
  63. tools: z.record(z.string(), z.boolean()).optional(),
  64. })
  65. .openapi({
  66. ref: "ModeConfig",
  67. })
  68. export type Mode = z.infer<typeof Mode>
  69. export const Keybinds = z
  70. .object({
  71. leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
  72. app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
  73. switch_mode: z.string().optional().default("tab").describe("Next mode"),
  74. switch_mode_reverse: z.string().optional().default("shift+tab").describe("Previous Mode"),
  75. editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
  76. session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
  77. session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
  78. session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
  79. session_share: z.string().optional().default("<leader>s").describe("Share current session"),
  80. session_unshare: z.string().optional().default("<leader>u").describe("Unshare current session"),
  81. session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
  82. session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
  83. tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
  84. model_list: z.string().optional().default("<leader>m").describe("List available models"),
  85. theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
  86. file_list: z.string().optional().default("<leader>f").describe("List files"),
  87. file_close: z.string().optional().default("esc").describe("Close file"),
  88. file_search: z.string().optional().default("<leader>/").describe("Search file"),
  89. file_diff_toggle: z.string().optional().default("<leader>v").describe("Split/unified diff"),
  90. project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
  91. input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
  92. input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
  93. input_submit: z.string().optional().default("enter").describe("Submit input"),
  94. input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
  95. messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
  96. messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
  97. messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
  98. messages_half_page_down: z
  99. .string()
  100. .optional()
  101. .default("ctrl+alt+d")
  102. .describe("Scroll messages down by half page"),
  103. messages_previous: z.string().optional().default("ctrl+up").describe("Navigate to previous message"),
  104. messages_next: z.string().optional().default("ctrl+down").describe("Navigate to next message"),
  105. messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
  106. messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
  107. messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
  108. messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
  109. messages_revert: z.string().optional().default("<leader>r").describe("Revert message"),
  110. app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
  111. })
  112. .strict()
  113. .openapi({
  114. ref: "KeybindsConfig",
  115. })
  116. export const Info = z
  117. .object({
  118. $schema: z.string().optional().describe("JSON schema reference for configuration validation"),
  119. theme: z.string().optional().describe("Theme name to use for the interface"),
  120. keybinds: Keybinds.optional().describe("Custom keybind configurations"),
  121. share: z
  122. .enum(["auto", "disabled"])
  123. .optional()
  124. .describe("Control sharing behavior: 'auto' enables automatic sharing, 'disabled' disables all sharing"),
  125. autoshare: z
  126. .boolean()
  127. .optional()
  128. .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
  129. autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
  130. disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
  131. model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
  132. username: z
  133. .string()
  134. .optional()
  135. .describe("Custom username to display in conversations instead of system username"),
  136. mode: z
  137. .object({
  138. build: Mode.optional(),
  139. plan: Mode.optional(),
  140. })
  141. .catchall(Mode)
  142. .optional(),
  143. log_level: Log.Level.optional().describe("Minimum log level to write to log files"),
  144. provider: z
  145. .record(
  146. ModelsDev.Provider.partial().extend({
  147. models: z.record(ModelsDev.Model.partial()),
  148. options: z.record(z.any()).optional(),
  149. }),
  150. )
  151. .optional()
  152. .describe("Custom provider configurations and model overrides"),
  153. mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
  154. instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
  155. experimental: z
  156. .object({
  157. hook: z
  158. .object({
  159. file_edited: z
  160. .record(
  161. z.string(),
  162. z
  163. .object({
  164. command: z.string().array(),
  165. environment: z.record(z.string(), z.string()).optional(),
  166. })
  167. .array(),
  168. )
  169. .optional(),
  170. session_completed: z
  171. .object({
  172. command: z.string().array(),
  173. environment: z.record(z.string(), z.string()).optional(),
  174. })
  175. .array()
  176. .optional(),
  177. })
  178. .optional(),
  179. })
  180. .optional(),
  181. })
  182. .strict()
  183. .openapi({
  184. ref: "Config",
  185. })
  186. export type Info = z.output<typeof Info>
  187. export const global = lazy(async () => {
  188. let result = pipe(
  189. {},
  190. mergeDeep(await load(path.join(Global.Path.config, "config.json"))),
  191. mergeDeep(await load(path.join(Global.Path.config, "opencode.json"))),
  192. )
  193. await import(path.join(Global.Path.config, "config"), {
  194. with: {
  195. type: "toml",
  196. },
  197. })
  198. .then(async (mod) => {
  199. const { provider, model, ...rest } = mod.default
  200. if (provider && model) result.model = `${provider}/${model}`
  201. result["$schema"] = "https://opencode.ai/config.json"
  202. result = mergeDeep(result, rest)
  203. await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
  204. await fs.unlink(path.join(Global.Path.config, "config"))
  205. })
  206. .catch(() => {})
  207. return result
  208. })
  209. async function load(configPath: string) {
  210. let text = await Bun.file(configPath)
  211. .text()
  212. .catch((err) => {
  213. if (err.code === "ENOENT") return
  214. throw new JsonError({ path: configPath }, { cause: err })
  215. })
  216. if (!text) return {}
  217. text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
  218. return process.env[varName] || ""
  219. })
  220. const fileMatches = text.match(/"?\{file:([^}]+)\}"?/g)
  221. if (fileMatches) {
  222. const configDir = path.dirname(configPath)
  223. for (const match of fileMatches) {
  224. const filePath = match.replace(/^"?\{file:/, "").replace(/\}"?$/, "")
  225. const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
  226. const fileContent = await Bun.file(resolvedPath).text()
  227. text = text.replace(match, JSON.stringify(fileContent))
  228. }
  229. }
  230. let data: any
  231. try {
  232. data = JSON.parse(text)
  233. } catch (err) {
  234. throw new JsonError({ path: configPath }, { cause: err as Error })
  235. }
  236. const parsed = Info.safeParse(data)
  237. if (parsed.success) {
  238. if (!parsed.data.$schema) {
  239. parsed.data.$schema = "https://opencode.ai/config.json"
  240. await Bun.write(configPath, JSON.stringify(parsed.data, null, 2))
  241. }
  242. return parsed.data
  243. }
  244. throw new InvalidError({ path: configPath, issues: parsed.error.issues })
  245. }
  246. export const JsonError = NamedError.create(
  247. "ConfigJsonError",
  248. z.object({
  249. path: z.string(),
  250. }),
  251. )
  252. export const InvalidError = NamedError.create(
  253. "ConfigInvalidError",
  254. z.object({
  255. path: z.string(),
  256. issues: z.custom<z.ZodIssue[]>().optional(),
  257. }),
  258. )
  259. export function get() {
  260. return state()
  261. }
  262. }