config.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  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 } 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. log.info("loaded", result)
  23. return result
  24. })
  25. export const McpLocal = z
  26. .object({
  27. type: z.literal("local").describe("Type of MCP server connection"),
  28. command: z
  29. .string()
  30. .array()
  31. .describe("Command and arguments to run the MCP server"),
  32. environment: z
  33. .record(z.string(), z.string())
  34. .optional()
  35. .describe("Environment variables to set when running the MCP server"),
  36. })
  37. .strict()
  38. .openapi({
  39. ref: "Config.McpLocal",
  40. })
  41. export const McpRemote = z
  42. .object({
  43. type: z.literal("remote").describe("Type of MCP server connection"),
  44. url: z.string().describe("URL of the remote MCP server"),
  45. })
  46. .strict()
  47. .openapi({
  48. ref: "Config.McpRemote",
  49. })
  50. export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
  51. export type Mcp = z.infer<typeof Mcp>
  52. export const Keybinds = z
  53. .object({
  54. leader: z
  55. .string()
  56. .optional()
  57. .describe("Leader key for keybind combinations"),
  58. help: z.string().optional().describe("Show help dialog"),
  59. editor_open: z.string().optional().describe("Open external editor"),
  60. session_new: z.string().optional().describe("Create a new session"),
  61. session_list: z.string().optional().describe("List all sessions"),
  62. session_share: z.string().optional().describe("Share current session"),
  63. session_interrupt: z
  64. .string()
  65. .optional()
  66. .describe("Interrupt current session"),
  67. session_compact: z
  68. .string()
  69. .optional()
  70. .describe("Toggle compact mode for session"),
  71. tool_details: z.string().optional().describe("Show tool details"),
  72. model_list: z.string().optional().describe("List available models"),
  73. theme_list: z.string().optional().describe("List available themes"),
  74. project_init: z
  75. .string()
  76. .optional()
  77. .describe("Initialize project configuration"),
  78. input_clear: z.string().optional().describe("Clear input field"),
  79. input_paste: z.string().optional().describe("Paste from clipboard"),
  80. input_submit: z.string().optional().describe("Submit input"),
  81. input_newline: z.string().optional().describe("Insert newline in input"),
  82. history_previous: z
  83. .string()
  84. .optional()
  85. .describe("Navigate to previous history item"),
  86. history_next: z
  87. .string()
  88. .optional()
  89. .describe("Navigate to next history item"),
  90. messages_page_up: z
  91. .string()
  92. .optional()
  93. .describe("Scroll messages up by one page"),
  94. messages_page_down: z
  95. .string()
  96. .optional()
  97. .describe("Scroll messages down by one page"),
  98. messages_half_page_up: z
  99. .string()
  100. .optional()
  101. .describe("Scroll messages up by half page"),
  102. messages_half_page_down: z
  103. .string()
  104. .optional()
  105. .describe("Scroll messages down by half page"),
  106. messages_previous: z
  107. .string()
  108. .optional()
  109. .describe("Navigate to previous message"),
  110. messages_next: z.string().optional().describe("Navigate to next message"),
  111. messages_first: z
  112. .string()
  113. .optional()
  114. .describe("Navigate to first message"),
  115. messages_last: z.string().optional().describe("Navigate to last message"),
  116. app_exit: z.string().optional().describe("Exit the application"),
  117. })
  118. .strict()
  119. .openapi({
  120. ref: "Config.Keybinds",
  121. })
  122. export const Info = z
  123. .object({
  124. $schema: z
  125. .string()
  126. .optional()
  127. .describe("JSON schema reference for configuration validation"),
  128. theme: z
  129. .string()
  130. .optional()
  131. .describe("Theme name to use for the interface"),
  132. keybinds: Keybinds.optional().describe("Custom keybind configurations"),
  133. autoshare: z
  134. .boolean()
  135. .optional()
  136. .describe("Share newly created sessions automatically"),
  137. autoupdate: z
  138. .boolean()
  139. .optional()
  140. .describe("Automatically update to the latest version"),
  141. disabled_providers: z
  142. .array(z.string())
  143. .optional()
  144. .describe("Disable providers that are loaded automatically"),
  145. model: z
  146. .string()
  147. .describe(
  148. "Model to use in the format of provider/model, eg anthropic/claude-2",
  149. )
  150. .optional(),
  151. provider: z
  152. .record(
  153. ModelsDev.Provider.partial().extend({
  154. models: z.record(ModelsDev.Model.partial()),
  155. options: z.record(z.any()).optional(),
  156. }),
  157. )
  158. .optional()
  159. .describe("Custom provider configurations and model overrides"),
  160. mcp: z
  161. .record(z.string(), Mcp)
  162. .optional()
  163. .describe("MCP (Model Context Protocol) server configurations"),
  164. })
  165. .strict()
  166. .openapi({
  167. ref: "Config.Info",
  168. })
  169. export type Info = z.output<typeof Info>
  170. export const global = lazy(async () => {
  171. let result = await load(path.join(Global.Path.config, "config.json"))
  172. await import(path.join(Global.Path.config, "config"), {
  173. with: {
  174. type: "toml",
  175. },
  176. })
  177. .then(async (mod) => {
  178. const { provider, model, ...rest } = mod.default
  179. if (provider && model) result.model = `${provider}/${model}`
  180. result["$schema"] = "https://opencode.ai/config.json"
  181. result = mergeDeep(result, rest)
  182. await Bun.write(
  183. path.join(Global.Path.config, "config.json"),
  184. JSON.stringify(result, null, 2),
  185. )
  186. await fs.unlink(path.join(Global.Path.config, "config"))
  187. })
  188. .catch(() => {})
  189. return result
  190. })
  191. async function load(path: string) {
  192. const data = await Bun.file(path)
  193. .json()
  194. .catch((err) => {
  195. if (err.code === "ENOENT") return {}
  196. throw new JsonError({ path }, { cause: err })
  197. })
  198. const parsed = Info.safeParse(data)
  199. if (parsed.success) return parsed.data
  200. throw new InvalidError({ path, issues: parsed.error.issues })
  201. }
  202. export const JsonError = NamedError.create(
  203. "ConfigJsonError",
  204. z.object({
  205. path: z.string(),
  206. }),
  207. )
  208. export const InvalidError = NamedError.create(
  209. "ConfigInvalidError",
  210. z.object({
  211. path: z.string(),
  212. issues: z.custom<z.ZodIssue[]>().optional(),
  213. }),
  214. )
  215. export function get() {
  216. return state()
  217. }
  218. }