config.ts 39 KB

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