config.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  1. import { Log } from "../util/log"
  2. import path from "path"
  3. import os from "os"
  4. import z from "zod/v4"
  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. import { Instance } from "../project/instance"
  17. import { LSPServer } from "../lsp/server"
  18. export namespace Config {
  19. const log = Log.create({ service: "config" })
  20. export const state = Instance.state(async () => {
  21. const auth = await Auth.all()
  22. let result = await global()
  23. for (const file of ["opencode.jsonc", "opencode.json"]) {
  24. const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
  25. for (const resolved of found.toReversed()) {
  26. result = mergeDeep(result, await loadFile(resolved))
  27. }
  28. }
  29. // Override with custom config if provided
  30. if (Flag.OPENCODE_CONFIG) {
  31. result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
  32. log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
  33. }
  34. if (Flag.OPENCODE_CONFIG_CONTENT) {
  35. result = mergeDeep(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
  36. log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
  37. }
  38. for (const [key, value] of Object.entries(auth)) {
  39. if (value.type === "wellknown") {
  40. process.env[value.key] = value.token
  41. const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
  42. result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
  43. }
  44. }
  45. result.agent = result.agent || {}
  46. result.mode = result.mode || {}
  47. result.plugin = result.plugin || []
  48. const directories = [
  49. Global.Path.config,
  50. ...(await Array.fromAsync(
  51. Filesystem.up({ targets: [".opencode"], start: Instance.directory, stop: Instance.worktree }),
  52. )),
  53. ]
  54. for (const dir of directories) {
  55. await assertValid(dir)
  56. result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
  57. result.agent = mergeDeep(result.agent, await loadAgent(dir))
  58. result.agent = mergeDeep(result.agent, await loadMode(dir))
  59. result.plugin.push(...(await loadPlugin(dir)))
  60. }
  61. // Migrate deprecated mode field to agent field
  62. for (const [name, mode] of Object.entries(result.mode)) {
  63. result.agent = mergeDeep(result.agent ?? {}, {
  64. [name]: {
  65. ...mode,
  66. mode: "primary" as const,
  67. },
  68. })
  69. }
  70. if (Flag.OPENCODE_PERMISSION) {
  71. result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
  72. }
  73. if (!result.username) result.username = os.userInfo().username
  74. // Handle migration from autoshare to share field
  75. if (result.autoshare === true && !result.share) {
  76. result.share = "auto"
  77. }
  78. if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
  79. result.keybinds.messages_undo = result.keybinds.messages_revert
  80. }
  81. // Handle migration from autoshare to share field
  82. if (result.autoshare === true && !result.share) {
  83. result.share = "auto"
  84. }
  85. if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
  86. result.keybinds.messages_undo = result.keybinds.messages_revert
  87. }
  88. if (result.keybinds?.switch_mode && !result.keybinds.switch_agent) {
  89. result.keybinds.switch_agent = result.keybinds.switch_mode
  90. }
  91. if (result.keybinds?.switch_mode_reverse && !result.keybinds.switch_agent_reverse) {
  92. result.keybinds.switch_agent_reverse = result.keybinds.switch_mode_reverse
  93. }
  94. if (result.keybinds?.switch_agent && !result.keybinds.agent_cycle) {
  95. result.keybinds.agent_cycle = result.keybinds.switch_agent
  96. }
  97. if (result.keybinds?.switch_agent_reverse && !result.keybinds.agent_cycle_reverse) {
  98. result.keybinds.agent_cycle_reverse = result.keybinds.switch_agent_reverse
  99. }
  100. return {
  101. config: result,
  102. directories,
  103. }
  104. })
  105. async function assertValid(dir: string) {
  106. const ALLOWED_DIRS = new Set(["agent", "command", "mode", "plugin", "tool"])
  107. const UNEXPECTED_DIR_GLOB = new Bun.Glob("*/")
  108. for await (const item of UNEXPECTED_DIR_GLOB.scan({ absolute: true, cwd: dir, onlyFiles: false })) {
  109. const dirname = path.basename(item)
  110. if (!ALLOWED_DIRS.has(dirname)) {
  111. throw new InvalidError({
  112. path: dir,
  113. message: `Unexpected directory "${dirname}" found in "${dir}". Only ${ALLOWED_DIRS.values().toArray().join(", ")} directories are allowed.`,
  114. })
  115. }
  116. }
  117. }
  118. const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
  119. async function loadCommand(dir: string) {
  120. const result: Record<string, Command> = {}
  121. for await (const item of COMMAND_GLOB.scan({ absolute: true, cwd: dir })) {
  122. const content = await Bun.file(item).text()
  123. const md = matter(content)
  124. if (!md.data) continue
  125. const name = (() => {
  126. const patterns = ["/.opencode/command/", "/command/"]
  127. const pattern = patterns.find((p) => item.includes(p))
  128. if (pattern) {
  129. const index = item.indexOf(pattern)
  130. return item.slice(index + pattern.length, -3)
  131. }
  132. return path.basename(item, ".md")
  133. })()
  134. const config = {
  135. name,
  136. ...md.data,
  137. template: md.content.trim(),
  138. }
  139. const parsed = Command.safeParse(config)
  140. if (parsed.success) {
  141. result[config.name] = parsed.data
  142. continue
  143. }
  144. throw new InvalidError({ path: item }, { cause: parsed.error })
  145. }
  146. return result
  147. }
  148. const AGENT_GLOB = new Bun.Glob("agent/**/*.md")
  149. async function loadAgent(dir: string) {
  150. const result: Record<string, Agent> = {}
  151. for await (const item of AGENT_GLOB.scan({ absolute: true, cwd: dir })) {
  152. const content = await Bun.file(item).text()
  153. const md = matter(content)
  154. if (!md.data) continue
  155. // Extract relative path from agent folder for nested agents
  156. let agentName = path.basename(item, ".md")
  157. const agentFolderPath = item.includes("/.opencode/agent/")
  158. ? item.split("/.opencode/agent/")[1]
  159. : item.includes("/agent/")
  160. ? item.split("/agent/")[1]
  161. : agentName + ".md"
  162. // If agent is in a subfolder, include folder path in name
  163. if (agentFolderPath.includes("/")) {
  164. const relativePath = agentFolderPath.replace(".md", "")
  165. const pathParts = relativePath.split("/")
  166. agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1]
  167. }
  168. const config = {
  169. name: agentName,
  170. ...md.data,
  171. prompt: md.content.trim(),
  172. }
  173. const parsed = Agent.safeParse(config)
  174. if (parsed.success) {
  175. result[config.name] = parsed.data
  176. continue
  177. }
  178. throw new InvalidError({ path: item }, { cause: parsed.error })
  179. }
  180. return result
  181. }
  182. const MODE_GLOB = new Bun.Glob("mode/*.md")
  183. async function loadMode(dir: string) {
  184. const result: Record<string, Agent> = {}
  185. for await (const item of MODE_GLOB.scan({ absolute: true, cwd: dir })) {
  186. const content = await Bun.file(item).text()
  187. const md = matter(content)
  188. if (!md.data) continue
  189. const config = {
  190. name: path.basename(item, ".md"),
  191. ...md.data,
  192. prompt: md.content.trim(),
  193. }
  194. const parsed = Agent.safeParse(config)
  195. if (parsed.success) {
  196. result[config.name] = {
  197. ...parsed.data,
  198. mode: "primary" as const,
  199. }
  200. continue
  201. }
  202. }
  203. return result
  204. }
  205. const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}")
  206. async function loadPlugin(dir: string) {
  207. const plugins: string[] = []
  208. for await (const item of PLUGIN_GLOB.scan({ absolute: true, cwd: dir })) {
  209. plugins.push("file://" + item)
  210. }
  211. return plugins
  212. }
  213. export const McpLocal = z
  214. .object({
  215. type: z.literal("local").describe("Type of MCP server connection"),
  216. command: z.string().array().describe("Command and arguments to run the MCP server"),
  217. environment: z
  218. .record(z.string(), z.string())
  219. .optional()
  220. .describe("Environment variables to set when running the MCP server"),
  221. enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
  222. })
  223. .strict()
  224. .meta({
  225. ref: "McpLocalConfig",
  226. })
  227. export const McpRemote = z
  228. .object({
  229. type: z.literal("remote").describe("Type of MCP server connection"),
  230. url: z.string().describe("URL of the remote MCP server"),
  231. enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
  232. headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
  233. })
  234. .strict()
  235. .meta({
  236. ref: "McpRemoteConfig",
  237. })
  238. export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
  239. export type Mcp = z.infer<typeof Mcp>
  240. export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
  241. export type Permission = z.infer<typeof Permission>
  242. export const Command = z.object({
  243. template: z.string(),
  244. description: z.string().optional(),
  245. agent: z.string().optional(),
  246. model: z.string().optional(),
  247. subtask: z.boolean().optional(),
  248. })
  249. export type Command = z.infer<typeof Command>
  250. export const Agent = z
  251. .object({
  252. model: z.string().optional(),
  253. temperature: z.number().optional(),
  254. top_p: z.number().optional(),
  255. prompt: z.string().optional(),
  256. tools: z.record(z.string(), z.boolean()).optional(),
  257. disable: z.boolean().optional(),
  258. description: z.string().optional().describe("Description of when to use the agent"),
  259. mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]).optional(),
  260. permission: z
  261. .object({
  262. edit: Permission.optional(),
  263. bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
  264. webfetch: Permission.optional(),
  265. })
  266. .optional(),
  267. })
  268. .catchall(z.any())
  269. .meta({
  270. ref: "AgentConfig",
  271. })
  272. export type Agent = z.infer<typeof Agent>
  273. export const Keybinds = z
  274. .object({
  275. leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
  276. app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
  277. app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
  278. editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
  279. theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
  280. project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
  281. tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
  282. thinking_blocks: z.string().optional().default("<leader>b").describe("Toggle thinking blocks"),
  283. session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
  284. session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
  285. session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
  286. session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
  287. session_share: z.string().optional().default("<leader>s").describe("Share current session"),
  288. session_unshare: z.string().optional().default("none").describe("Unshare current session"),
  289. session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
  290. session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
  291. session_child_cycle: z.string().optional().default("ctrl+right").describe("Cycle to next child session"),
  292. session_child_cycle_reverse: z
  293. .string()
  294. .optional()
  295. .default("ctrl+left")
  296. .describe("Cycle to previous child session"),
  297. messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
  298. messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
  299. messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
  300. messages_half_page_down: z
  301. .string()
  302. .optional()
  303. .default("ctrl+alt+d")
  304. .describe("Scroll messages down by half page"),
  305. messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
  306. messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
  307. messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
  308. messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
  309. messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
  310. model_list: z.string().optional().default("<leader>m").describe("List available models"),
  311. model_cycle_recent: z.string().optional().default("f2").describe("Next recent model"),
  312. model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recent model"),
  313. agent_list: z.string().optional().default("<leader>a").describe("List agents"),
  314. agent_cycle: z.string().optional().default("tab").describe("Next agent"),
  315. agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
  316. input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
  317. input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
  318. input_submit: z.string().optional().default("enter").describe("Submit input"),
  319. input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
  320. // Deprecated commands
  321. switch_mode: z.string().optional().default("none").describe("@deprecated use agent_cycle. Next mode"),
  322. switch_mode_reverse: z
  323. .string()
  324. .optional()
  325. .default("none")
  326. .describe("@deprecated use agent_cycle_reverse. Previous mode"),
  327. switch_agent: z.string().optional().default("tab").describe("@deprecated use agent_cycle. Next agent"),
  328. switch_agent_reverse: z
  329. .string()
  330. .optional()
  331. .default("shift+tab")
  332. .describe("@deprecated use agent_cycle_reverse. Previous agent"),
  333. file_list: z.string().optional().default("none").describe("@deprecated Currently not available. List files"),
  334. file_close: z.string().optional().default("none").describe("@deprecated Close file"),
  335. file_search: z.string().optional().default("none").describe("@deprecated Search file"),
  336. file_diff_toggle: z.string().optional().default("none").describe("@deprecated Split/unified diff"),
  337. messages_previous: z.string().optional().default("none").describe("@deprecated Navigate to previous message"),
  338. messages_next: z.string().optional().default("none").describe("@deprecated Navigate to next message"),
  339. messages_layout_toggle: z.string().optional().default("none").describe("@deprecated Toggle layout"),
  340. messages_revert: z.string().optional().default("none").describe("@deprecated use messages_undo. Revert message"),
  341. })
  342. .strict()
  343. .meta({
  344. ref: "KeybindsConfig",
  345. })
  346. export const TUI = z.object({
  347. scroll_speed: z.number().min(1).optional().default(2).describe("TUI scroll speed"),
  348. })
  349. export const Layout = z.enum(["auto", "stretch"]).meta({
  350. ref: "LayoutConfig",
  351. })
  352. export type Layout = z.infer<typeof Layout>
  353. export const Info = z
  354. .object({
  355. $schema: z.string().optional().describe("JSON schema reference for configuration validation"),
  356. theme: z.string().optional().describe("Theme name to use for the interface"),
  357. keybinds: Keybinds.optional().describe("Custom keybind configurations"),
  358. tui: TUI.optional().describe("TUI specific settings"),
  359. command: z
  360. .record(z.string(), Command)
  361. .optional()
  362. .describe("Command configuration, see https://opencode.ai/docs/commands"),
  363. watcher: z
  364. .object({
  365. ignore: z.array(z.string()).optional(),
  366. })
  367. .optional(),
  368. plugin: z.string().array().optional(),
  369. snapshot: z.boolean().optional(),
  370. share: z
  371. .enum(["manual", "auto", "disabled"])
  372. .optional()
  373. .describe(
  374. "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
  375. ),
  376. autoshare: z
  377. .boolean()
  378. .optional()
  379. .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
  380. autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
  381. disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
  382. model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
  383. small_model: z
  384. .string()
  385. .describe("Small model to use for tasks like title generation in the format of provider/model")
  386. .optional(),
  387. username: z
  388. .string()
  389. .optional()
  390. .describe("Custom username to display in conversations instead of system username"),
  391. mode: z
  392. .object({
  393. build: Agent.optional(),
  394. plan: Agent.optional(),
  395. })
  396. .catchall(Agent)
  397. .optional()
  398. .describe("@deprecated Use `agent` field instead."),
  399. agent: z
  400. .object({
  401. plan: Agent.optional(),
  402. build: Agent.optional(),
  403. general: Agent.optional(),
  404. })
  405. .catchall(Agent)
  406. .optional()
  407. .describe("Agent configuration, see https://opencode.ai/docs/agent"),
  408. provider: z
  409. .record(
  410. z.string(),
  411. ModelsDev.Provider.partial()
  412. .extend({
  413. models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
  414. options: z
  415. .object({
  416. apiKey: z.string().optional(),
  417. baseURL: z.string().optional(),
  418. timeout: z
  419. .union([
  420. z
  421. .number()
  422. .int()
  423. .positive()
  424. .describe(
  425. "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
  426. ),
  427. z.literal(false).describe("Disable timeout for this provider entirely."),
  428. ])
  429. .optional()
  430. .describe(
  431. "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
  432. ),
  433. })
  434. .catchall(z.any())
  435. .optional(),
  436. })
  437. .strict(),
  438. )
  439. .optional()
  440. .describe("Custom provider configurations and model overrides"),
  441. mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
  442. formatter: z
  443. .record(
  444. z.string(),
  445. z.object({
  446. disabled: z.boolean().optional(),
  447. command: z.array(z.string()).optional(),
  448. environment: z.record(z.string(), z.string()).optional(),
  449. extensions: z.array(z.string()).optional(),
  450. }),
  451. )
  452. .optional(),
  453. lsp: z
  454. .record(
  455. z.string(),
  456. z.union([
  457. z.object({
  458. disabled: z.literal(true),
  459. }),
  460. z.object({
  461. command: z.array(z.string()),
  462. extensions: z.array(z.string()).optional(),
  463. disabled: z.boolean().optional(),
  464. env: z.record(z.string(), z.string()).optional(),
  465. initialization: z.record(z.string(), z.any()).optional(),
  466. }),
  467. ]),
  468. )
  469. .optional()
  470. .refine(
  471. (data) => {
  472. if (!data) return true
  473. const serverIds = new Set(Object.values(LSPServer).map((s) => s.id))
  474. return Object.entries(data).every(([id, config]) => {
  475. if (config.disabled) return true
  476. if (serverIds.has(id)) return true
  477. return Boolean(config.extensions)
  478. })
  479. },
  480. {
  481. error: "For custom LSP servers, 'extensions' array is required.",
  482. },
  483. ),
  484. instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
  485. layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
  486. permission: z
  487. .object({
  488. edit: Permission.optional(),
  489. bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
  490. webfetch: Permission.optional(),
  491. })
  492. .optional(),
  493. tools: z.record(z.string(), z.boolean()).optional(),
  494. experimental: z
  495. .object({
  496. hook: z
  497. .object({
  498. file_edited: z
  499. .record(
  500. z.string(),
  501. z
  502. .object({
  503. command: z.string().array(),
  504. environment: z.record(z.string(), z.string()).optional(),
  505. })
  506. .array(),
  507. )
  508. .optional(),
  509. session_completed: z
  510. .object({
  511. command: z.string().array(),
  512. environment: z.record(z.string(), z.string()).optional(),
  513. })
  514. .array()
  515. .optional(),
  516. })
  517. .optional(),
  518. disable_paste_summary: z.boolean().optional(),
  519. })
  520. .optional(),
  521. })
  522. .strict()
  523. .meta({
  524. ref: "Config",
  525. })
  526. export type Info = z.output<typeof Info>
  527. export const global = lazy(async () => {
  528. let result: Info = pipe(
  529. {},
  530. mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
  531. mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))),
  532. mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
  533. )
  534. await import(path.join(Global.Path.config, "config"), {
  535. with: {
  536. type: "toml",
  537. },
  538. })
  539. .then(async (mod) => {
  540. const { provider, model, ...rest } = mod.default
  541. if (provider && model) result.model = `${provider}/${model}`
  542. result["$schema"] = "https://opencode.ai/config.json"
  543. result = mergeDeep(result, rest)
  544. await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
  545. await fs.unlink(path.join(Global.Path.config, "config"))
  546. })
  547. .catch(() => {})
  548. return result
  549. })
  550. async function loadFile(filepath: string): Promise<Info> {
  551. log.info("loading", { path: filepath })
  552. let text = await Bun.file(filepath)
  553. .text()
  554. .catch((err) => {
  555. if (err.code === "ENOENT") return
  556. throw new JsonError({ path: filepath }, { cause: err })
  557. })
  558. if (!text) return {}
  559. return load(text, filepath)
  560. }
  561. async function load(text: string, configFilepath: string) {
  562. text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
  563. return process.env[varName] || ""
  564. })
  565. const fileMatches = text.match(/\{file:[^}]+\}/g)
  566. if (fileMatches) {
  567. const configDir = path.dirname(configFilepath)
  568. const lines = text.split("\n")
  569. for (const match of fileMatches) {
  570. const lineIndex = lines.findIndex((line) => line.includes(match))
  571. if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
  572. continue // Skip if line is commented
  573. }
  574. let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
  575. if (filePath.startsWith("~/")) {
  576. filePath = path.join(os.homedir(), filePath.slice(2))
  577. }
  578. const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
  579. const fileContent = (
  580. await Bun.file(resolvedPath)
  581. .text()
  582. .catch((error) => {
  583. const errMsg = `bad file reference: "${match}"`
  584. if (error.code === "ENOENT") {
  585. throw new InvalidError(
  586. { path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist` },
  587. { cause: error },
  588. )
  589. }
  590. throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
  591. })
  592. ).trim()
  593. // escape newlines/quotes, strip outer quotes
  594. text = text.replace(match, JSON.stringify(fileContent).slice(1, -1))
  595. }
  596. }
  597. const errors: JsoncParseError[] = []
  598. const data = parseJsonc(text, errors, { allowTrailingComma: true })
  599. if (errors.length) {
  600. const lines = text.split("\n")
  601. const errorDetails = errors
  602. .map((e) => {
  603. const beforeOffset = text.substring(0, e.offset).split("\n")
  604. const line = beforeOffset.length
  605. const column = beforeOffset[beforeOffset.length - 1].length + 1
  606. const problemLine = lines[line - 1]
  607. const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
  608. if (!problemLine) return error
  609. return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
  610. })
  611. .join("\n")
  612. throw new JsonError({
  613. path: configFilepath,
  614. message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
  615. })
  616. }
  617. const parsed = Info.safeParse(data)
  618. if (parsed.success) {
  619. if (!parsed.data.$schema) {
  620. parsed.data.$schema = "https://opencode.ai/config.json"
  621. await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2))
  622. }
  623. const data = parsed.data
  624. if (data.plugin) {
  625. for (let i = 0; i < data.plugin?.length; i++) {
  626. const plugin = data.plugin[i]
  627. try {
  628. data.plugin[i] = import.meta.resolve(plugin, configFilepath)
  629. } catch (err) {}
  630. }
  631. }
  632. return data
  633. }
  634. throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
  635. }
  636. export const JsonError = NamedError.create(
  637. "ConfigJsonError",
  638. z.object({
  639. path: z.string(),
  640. message: z.string().optional(),
  641. }),
  642. )
  643. export const InvalidError = NamedError.create(
  644. "ConfigInvalidError",
  645. z.object({
  646. path: z.string(),
  647. issues: z.custom<z.core.$ZodIssue[]>().optional(),
  648. message: z.string().optional(),
  649. }),
  650. )
  651. export async function get() {
  652. return state().then((x) => x.config)
  653. }
  654. export async function update(config: Info) {
  655. const filepath = path.join(Instance.directory, "config.json")
  656. const existing = await loadFile(filepath)
  657. await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
  658. await Instance.dispose()
  659. }
  660. export async function directories() {
  661. return state().then((x) => x.directories)
  662. }
  663. }