config.ts 32 KB

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