config.ts 12 KB

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