2
0

config.ts 54 KB

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