config.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. import { Log } from "../util/log"
  2. import path from "path"
  3. import os from "os"
  4. import { z } from "zod"
  5. import { App } from "../app/app"
  6. import { Filesystem } from "../util/filesystem"
  7. import { ModelsDev } from "../provider/models"
  8. import { mergeDeep, pipe } from "remeda"
  9. import { Global } from "../global"
  10. import fs from "fs/promises"
  11. import { lazy } from "../util/lazy"
  12. import { NamedError } from "../util/error"
  13. import matter from "gray-matter"
  14. import { Flag } from "../flag/flag"
  15. import { Auth } from "../auth"
  16. import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
  17. export namespace Config {
  18. const log = Log.create({ service: "config" })
  19. export const state = App.state("config", async (app) => {
  20. const auth = await Auth.all()
  21. let result = await global()
  22. for (const file of ["opencode.jsonc", "opencode.json"]) {
  23. const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
  24. for (const resolved of found.toReversed()) {
  25. result = mergeDeep(result, await loadFile(resolved))
  26. }
  27. }
  28. // Override with custom config if provided
  29. if (Flag.OPENCODE_CONFIG) {
  30. result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
  31. log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
  32. }
  33. for (const [key, value] of Object.entries(auth)) {
  34. if (value.type === "wellknown") {
  35. process.env[value.key] = value.token
  36. const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
  37. result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
  38. }
  39. }
  40. result.agent = result.agent || {}
  41. const markdownAgents = [
  42. ...(await Filesystem.globUp("agent/*.md", Global.Path.config, Global.Path.config)),
  43. ...(await Filesystem.globUp(".opencode/agent/*.md", app.path.cwd, app.path.root)),
  44. ]
  45. for (const item of markdownAgents) {
  46. const content = await Bun.file(item).text()
  47. const md = matter(content)
  48. if (!md.data) continue
  49. const config = {
  50. name: path.basename(item, ".md"),
  51. ...md.data,
  52. prompt: md.content.trim(),
  53. }
  54. const parsed = Agent.safeParse(config)
  55. if (parsed.success) {
  56. result.agent = mergeDeep(result.agent, {
  57. [config.name]: parsed.data,
  58. })
  59. continue
  60. }
  61. throw new InvalidError({ path: item }, { cause: parsed.error })
  62. }
  63. // Load mode markdown files
  64. result.mode = result.mode || {}
  65. const markdownModes = [
  66. ...(await Filesystem.globUp("mode/*.md", Global.Path.config, Global.Path.config)),
  67. ...(await Filesystem.globUp(".opencode/mode/*.md", app.path.cwd, app.path.root)),
  68. ]
  69. for (const item of markdownModes) {
  70. const content = await Bun.file(item).text()
  71. const md = matter(content)
  72. if (!md.data) continue
  73. const config = {
  74. name: path.basename(item, ".md"),
  75. ...md.data,
  76. prompt: md.content.trim(),
  77. }
  78. const parsed = Agent.safeParse(config)
  79. if (parsed.success) {
  80. result.mode = mergeDeep(result.mode, {
  81. [config.name]: parsed.data,
  82. })
  83. continue
  84. }
  85. throw new InvalidError({ path: item }, { cause: parsed.error })
  86. }
  87. // Migrate deprecated mode field to agent field
  88. for (const [name, mode] of Object.entries(result.mode)) {
  89. result.agent = mergeDeep(result.agent ?? {}, {
  90. [name]: {
  91. ...mode,
  92. mode: "primary" as const,
  93. },
  94. })
  95. }
  96. result.plugin = result.plugin || []
  97. result.plugin.push(
  98. ...[
  99. ...(await Filesystem.globUp("plugin/*.{ts,js}", Global.Path.config, Global.Path.config)),
  100. ...(await Filesystem.globUp(".opencode/plugin/*.{ts,js}", app.path.cwd, app.path.root)),
  101. ].map((x) => "file://" + x),
  102. )
  103. if (Flag.OPENCODE_PERMISSION) {
  104. result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
  105. }
  106. // Handle migration from autoshare to share field
  107. if (result.autoshare === true && !result.share) {
  108. result.share = "auto"
  109. }
  110. if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
  111. result.keybinds.messages_undo = result.keybinds.messages_revert
  112. }
  113. if (result.keybinds?.switch_mode && !result.keybinds.switch_agent) {
  114. result.keybinds.switch_agent = result.keybinds.switch_mode
  115. }
  116. if (result.keybinds?.switch_mode_reverse && !result.keybinds.switch_agent_reverse) {
  117. result.keybinds.switch_agent_reverse = result.keybinds.switch_mode_reverse
  118. }
  119. if (!result.username) {
  120. const os = await import("os")
  121. result.username = os.userInfo().username
  122. }
  123. log.info("loaded", result)
  124. return result
  125. })
  126. export const McpLocal = z
  127. .object({
  128. type: z.literal("local").describe("Type of MCP server connection"),
  129. command: z.string().array().describe("Command and arguments to run the MCP server"),
  130. environment: z
  131. .record(z.string(), z.string())
  132. .optional()
  133. .describe("Environment variables to set when running the MCP server"),
  134. enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
  135. })
  136. .strict()
  137. .openapi({
  138. ref: "McpLocalConfig",
  139. })
  140. export const McpRemote = z
  141. .object({
  142. type: z.literal("remote").describe("Type of MCP server connection"),
  143. url: z.string().describe("URL of the remote MCP server"),
  144. enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
  145. headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
  146. })
  147. .strict()
  148. .openapi({
  149. ref: "McpRemoteConfig",
  150. })
  151. export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
  152. export type Mcp = z.infer<typeof Mcp>
  153. export const Permission = z.union([z.literal("ask"), z.literal("allow"), z.literal("deny")])
  154. export type Permission = z.infer<typeof Permission>
  155. export const Agent = z
  156. .object({
  157. model: z.string().optional(),
  158. temperature: z.number().optional(),
  159. top_p: z.number().optional(),
  160. prompt: z.string().optional(),
  161. tools: z.record(z.string(), z.boolean()).optional(),
  162. disable: z.boolean().optional(),
  163. description: z.string().optional().describe("Description of when to use the agent"),
  164. mode: z.union([z.literal("subagent"), z.literal("primary"), z.literal("all")]).optional(),
  165. permission: z
  166. .object({
  167. edit: Permission.optional(),
  168. bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
  169. webfetch: Permission.optional(),
  170. })
  171. .optional(),
  172. })
  173. .catchall(z.any())
  174. .openapi({
  175. ref: "AgentConfig",
  176. })
  177. export type Agent = z.infer<typeof Agent>
  178. export const Keybinds = z
  179. .object({
  180. leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
  181. app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
  182. switch_mode: z.string().optional().default("none").describe("@deprecated use switch_agent. Next mode"),
  183. switch_mode_reverse: z
  184. .string()
  185. .optional()
  186. .default("none")
  187. .describe("@deprecated use switch_agent_reverse. Previous mode"),
  188. switch_agent: z.string().optional().default("tab").describe("Next agent"),
  189. switch_agent_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
  190. editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
  191. session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
  192. session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
  193. session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
  194. session_share: z.string().optional().default("<leader>s").describe("Share current session"),
  195. session_unshare: z.string().optional().default("none").describe("Unshare current session"),
  196. session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
  197. session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
  198. tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
  199. thinking_blocks: z.string().optional().default("<leader>b").describe("Toggle thinking blocks"),
  200. model_list: z.string().optional().default("<leader>m").describe("List available models"),
  201. theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
  202. file_list: z.string().optional().default("<leader>f").describe("List files"),
  203. file_close: z.string().optional().default("esc").describe("Close file"),
  204. file_search: z.string().optional().default("<leader>/").describe("Search file"),
  205. file_diff_toggle: z.string().optional().default("<leader>v").describe("Split/unified diff"),
  206. project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
  207. input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
  208. input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
  209. input_submit: z.string().optional().default("enter").describe("Submit input"),
  210. input_newline: z.string().optional().default("shift+enter,ctrl+j").describe("Insert newline in input"),
  211. messages_page_up: z.string().optional().default("pgup").describe("Scroll messages up by one page"),
  212. messages_page_down: z.string().optional().default("pgdown").describe("Scroll messages down by one page"),
  213. messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
  214. messages_half_page_down: z
  215. .string()
  216. .optional()
  217. .default("ctrl+alt+d")
  218. .describe("Scroll messages down by half page"),
  219. messages_previous: z.string().optional().default("ctrl+up").describe("Navigate to previous message"),
  220. messages_next: z.string().optional().default("ctrl+down").describe("Navigate to next message"),
  221. messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
  222. messages_last: z.string().optional().default("ctrl+alt+g").describe("Navigate to last message"),
  223. messages_layout_toggle: z.string().optional().default("<leader>p").describe("Toggle layout"),
  224. messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
  225. messages_revert: z.string().optional().default("none").describe("@deprecated use messages_undo. Revert message"),
  226. messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
  227. messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
  228. app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
  229. })
  230. .strict()
  231. .openapi({
  232. ref: "KeybindsConfig",
  233. })
  234. export const Layout = z.enum(["auto", "stretch"]).openapi({
  235. ref: "LayoutConfig",
  236. })
  237. export type Layout = z.infer<typeof Layout>
  238. export const Info = z
  239. .object({
  240. $schema: z.string().optional().describe("JSON schema reference for configuration validation"),
  241. theme: z.string().optional().describe("Theme name to use for the interface"),
  242. keybinds: Keybinds.optional().describe("Custom keybind configurations"),
  243. plugin: z.string().array().optional(),
  244. snapshot: z.boolean().optional(),
  245. share: z
  246. .enum(["manual", "auto", "disabled"])
  247. .optional()
  248. .describe(
  249. "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
  250. ),
  251. autoshare: z
  252. .boolean()
  253. .optional()
  254. .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
  255. autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
  256. disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
  257. model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
  258. small_model: z
  259. .string()
  260. .describe("Small model to use for tasks like title generation in the format of provider/model")
  261. .optional(),
  262. username: z
  263. .string()
  264. .optional()
  265. .describe("Custom username to display in conversations instead of system username"),
  266. mode: z
  267. .object({
  268. build: Agent.optional(),
  269. plan: Agent.optional(),
  270. })
  271. .catchall(Agent)
  272. .optional()
  273. .describe("@deprecated Use `agent` field instead."),
  274. agent: z
  275. .object({
  276. plan: Agent.optional(),
  277. build: Agent.optional(),
  278. general: Agent.optional(),
  279. })
  280. .catchall(Agent)
  281. .optional()
  282. .describe("Agent configuration, see https://opencode.ai/docs/agent"),
  283. provider: z
  284. .record(
  285. ModelsDev.Provider.partial()
  286. .extend({
  287. models: z.record(ModelsDev.Model.partial()).optional(),
  288. options: z
  289. .object({
  290. apiKey: z.string().optional(),
  291. baseURL: z.string().optional(),
  292. })
  293. .catchall(z.any())
  294. .optional(),
  295. })
  296. .strict(),
  297. )
  298. .optional()
  299. .describe("Custom provider configurations and model overrides"),
  300. mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
  301. formatter: z
  302. .record(
  303. z.string(),
  304. z.object({
  305. disabled: z.boolean().optional(),
  306. command: z.array(z.string()).optional(),
  307. environment: z.record(z.string(), z.string()).optional(),
  308. extensions: z.array(z.string()).optional(),
  309. }),
  310. )
  311. .optional(),
  312. lsp: z
  313. .record(
  314. z.string(),
  315. z.union([
  316. z.object({
  317. disabled: z.literal(true),
  318. }),
  319. z.object({
  320. command: z.array(z.string()),
  321. extensions: z.array(z.string()).optional(),
  322. disabled: z.boolean().optional(),
  323. env: z.record(z.string(), z.string()).optional(),
  324. initialization: z.record(z.string(), z.any()).optional(),
  325. }),
  326. ]),
  327. )
  328. .optional(),
  329. instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
  330. layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
  331. permission: z
  332. .object({
  333. edit: Permission.optional(),
  334. bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
  335. webfetch: Permission.optional(),
  336. })
  337. .optional(),
  338. experimental: z
  339. .object({
  340. hook: z
  341. .object({
  342. file_edited: z
  343. .record(
  344. z.string(),
  345. z
  346. .object({
  347. command: z.string().array(),
  348. environment: z.record(z.string(), z.string()).optional(),
  349. })
  350. .array(),
  351. )
  352. .optional(),
  353. session_completed: z
  354. .object({
  355. command: z.string().array(),
  356. environment: z.record(z.string(), z.string()).optional(),
  357. })
  358. .array()
  359. .optional(),
  360. })
  361. .optional(),
  362. })
  363. .optional(),
  364. })
  365. .strict()
  366. .openapi({
  367. ref: "Config",
  368. })
  369. export type Info = z.output<typeof Info>
  370. export const global = lazy(async () => {
  371. let result: Info = pipe(
  372. {},
  373. mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
  374. mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))),
  375. mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
  376. )
  377. await import(path.join(Global.Path.config, "config"), {
  378. with: {
  379. type: "toml",
  380. },
  381. })
  382. .then(async (mod) => {
  383. const { provider, model, ...rest } = mod.default
  384. if (provider && model) result.model = `${provider}/${model}`
  385. result["$schema"] = "https://opencode.ai/config.json"
  386. result = mergeDeep(result, rest)
  387. await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
  388. await fs.unlink(path.join(Global.Path.config, "config"))
  389. })
  390. .catch(() => {})
  391. return result
  392. })
  393. async function loadFile(filepath: string): Promise<Info> {
  394. log.info("loading", { path: filepath })
  395. let text = await Bun.file(filepath)
  396. .text()
  397. .catch((err) => {
  398. if (err.code === "ENOENT") return
  399. throw new JsonError({ path: filepath }, { cause: err })
  400. })
  401. if (!text) return {}
  402. return load(text, filepath)
  403. }
  404. async function load(text: string, configFilepath: string) {
  405. text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
  406. return process.env[varName] || ""
  407. })
  408. const fileMatches = text.match(/\{file:[^}]+\}/g)
  409. if (fileMatches) {
  410. const configDir = path.dirname(configFilepath)
  411. const lines = text.split("\n")
  412. for (const match of fileMatches) {
  413. const lineIndex = lines.findIndex((line) => line.includes(match))
  414. if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
  415. continue // Skip if line is commented
  416. }
  417. let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
  418. if (filePath.startsWith("~/")) {
  419. filePath = path.join(os.homedir(), filePath.slice(2))
  420. }
  421. const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
  422. const fileContent = (
  423. await Bun.file(resolvedPath)
  424. .text()
  425. .catch((error) => {
  426. const errMsg = `bad file reference: "${match}"`
  427. if (error.code === "ENOENT") {
  428. throw new InvalidError(
  429. { path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist` },
  430. { cause: error },
  431. )
  432. }
  433. throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
  434. })
  435. ).trim()
  436. // escape newlines/quotes, strip outer quotes
  437. text = text.replace(match, JSON.stringify(fileContent).slice(1, -1))
  438. }
  439. }
  440. const errors: JsoncParseError[] = []
  441. const data = parseJsonc(text, errors, { allowTrailingComma: true })
  442. if (errors.length) {
  443. const lines = text.split("\n")
  444. const errorDetails = errors
  445. .map((e) => {
  446. const beforeOffset = text.substring(0, e.offset).split("\n")
  447. const line = beforeOffset.length
  448. const column = beforeOffset[beforeOffset.length - 1].length + 1
  449. const problemLine = lines[line - 1]
  450. const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
  451. if (!problemLine) return error
  452. return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
  453. })
  454. .join("\n")
  455. throw new JsonError({
  456. path: configFilepath,
  457. message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
  458. })
  459. }
  460. const parsed = Info.safeParse(data)
  461. if (parsed.success) {
  462. if (!parsed.data.$schema) {
  463. parsed.data.$schema = "https://opencode.ai/config.json"
  464. await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2))
  465. }
  466. const data = parsed.data
  467. if (data.plugin) {
  468. for (let i = 0; i < data.plugin?.length; i++) {
  469. const plugin = data.plugin[i]
  470. try {
  471. data.plugin[i] = import.meta.resolve(plugin, configFilepath)
  472. } catch (err) {}
  473. }
  474. }
  475. return data
  476. }
  477. throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
  478. }
  479. export const JsonError = NamedError.create(
  480. "ConfigJsonError",
  481. z.object({
  482. path: z.string(),
  483. message: z.string().optional(),
  484. }),
  485. )
  486. export const InvalidError = NamedError.create(
  487. "ConfigInvalidError",
  488. z.object({
  489. path: z.string(),
  490. issues: z.custom<z.ZodIssue[]>().optional(),
  491. message: z.string().optional(),
  492. }),
  493. )
  494. export function get() {
  495. return state()
  496. }
  497. }