config.ts 43 KB

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