config.ts 29 KB

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