config.ts 54 KB

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