config.ts 53 KB

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