config.ts 25 KB

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