config.ts 36 KB

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