config.ts 17 KB

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