config.ts 14 KB

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