config.ts 55 KB

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