import { Log } from "../util/log" import path from "path" import { pathToFileURL } from "url" import os from "os" import z from "zod" import { Filesystem } from "../util/filesystem" import { ModelsDev } from "../provider/models" import { mergeDeep, pipe, unique } from "remeda" import { Global } from "../global" import fs from "fs/promises" import { lazy } from "../util/lazy" import { NamedError } from "@opencode-ai/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" import { Instance } from "../project/instance" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" export namespace Config { const log = Log.create({ service: "config" }) // Custom merge function that concatenates plugin arrays instead of replacing them function mergeConfigWithPlugins(target: Info, source: Info): Info { const merged = mergeDeep(target, source) // If both configs have plugin arrays, concatenate them instead of replacing if (target.plugin && source.plugin) { const pluginSet = new Set([...target.plugin, ...source.plugin]) merged.plugin = Array.from(pluginSet) } return merged } export const state = Instance.state(async () => { const auth = await Auth.all() let result = await global() // Override with custom config if provided if (Flag.OPENCODE_CONFIG) { result = mergeConfigWithPlugins(result, await loadFile(Flag.OPENCODE_CONFIG)) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } for (const file of ["opencode.jsonc", "opencode.json"]) { const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) for (const resolved of found.toReversed()) { result = mergeConfigWithPlugins(result, await loadFile(resolved)) } } if (Flag.OPENCODE_CONFIG_CONTENT) { result = mergeConfigWithPlugins(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { process.env[value.key] = value.token const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any result = mergeConfigWithPlugins(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd())) } } result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] const directories = [ Global.Path.config, ...(await Array.fromAsync( Filesystem.up({ targets: [".opencode"], start: Instance.directory, stop: Instance.worktree, }), )), ...(await Array.fromAsync( Filesystem.up({ targets: [".opencode"], start: Global.Path.home, stop: Global.Path.home, }), )), ] if (Flag.OPENCODE_CONFIG_DIR) { directories.push(Flag.OPENCODE_CONFIG_DIR) log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) } const promises: Promise[] = [] for (const dir of unique(directories)) { await assertValid(dir) if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.jsonc", "opencode.json"]) { log.debug(`loading config from ${path.join(dir, file)}`) result = mergeConfigWithPlugins(result, await loadFile(path.join(dir, file))) // to satisy the type checker result.agent ??= {} result.mode ??= {} result.plugin ??= [] } } promises.push(installDependencies(dir)) result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) result.agent = mergeDeep(result.agent, await loadMode(dir)) result.plugin.push(...(await loadPlugin(dir))) } await Promise.allSettled(promises) // Migrate deprecated mode field to agent field for (const [name, mode] of Object.entries(result.mode)) { result.agent = mergeDeep(result.agent ?? {}, { [name]: { ...mode, mode: "primary" as const, }, }) } if (Flag.OPENCODE_PERMISSION) { result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) } if (!result.username) result.username = os.userInfo().username // Handle migration from autoshare to share field if (result.autoshare === true && !result.share) { result.share = "auto" } // Handle migration from autoshare to share field if (result.autoshare === true && !result.share) { result.share = "auto" } if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({}) return { config: result, directories, } }) const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools"].join(",")}}/`) async function assertValid(dir: string) { const invalid = await Array.fromAsync( INVALID_DIRS.scan({ onlyFiles: false, cwd: dir, }), ) for (const item of invalid) { throw new ConfigDirectoryTypoError({ path: dir, dir: item, suggestion: item.substring(0, item.length - 1), }) } } async function installDependencies(dir: string) { if (Installation.isLocal()) return const pkg = path.join(dir, "package.json") if (!(await Bun.file(pkg).exists())) { await Bun.write(pkg, "{}") } const gitignore = path.join(dir, ".gitignore") const hasGitIgnore = await Bun.file(gitignore).exists() if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) await BunProc.run( ["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"], { cwd: dir, }, ).catch(() => {}) } const COMMAND_GLOB = new Bun.Glob("command/**/*.md") async function loadCommand(dir: string) { const result: Record = {} for await (const item of COMMAND_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { const md = await ConfigMarkdown.parse(item) if (!md.data) continue const name = (() => { const patterns = ["/.opencode/command/", "/command/"] const pattern = patterns.find((p) => item.includes(p)) if (pattern) { const index = item.indexOf(pattern) return item.slice(index + pattern.length, -3) } return path.basename(item, ".md") })() const config = { name, ...md.data, template: md.content.trim(), } const parsed = Command.safeParse(config) if (parsed.success) { result[config.name] = parsed.data continue } throw new InvalidError({ path: item }, { cause: parsed.error }) } return result } const AGENT_GLOB = new Bun.Glob("agent/**/*.md") async function loadAgent(dir: string) { const result: Record = {} for await (const item of AGENT_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { const md = await ConfigMarkdown.parse(item) if (!md.data) continue // Extract relative path from agent folder for nested agents let agentName = path.basename(item, ".md") const agentFolderPath = item.includes("/.opencode/agent/") ? item.split("/.opencode/agent/")[1] : item.includes("/agent/") ? item.split("/agent/")[1] : agentName + ".md" // If agent is in a subfolder, include folder path in name if (agentFolderPath.includes("/")) { const relativePath = agentFolderPath.replace(".md", "") const pathParts = relativePath.split("/") agentName = pathParts.slice(0, -1).join("/") + "/" + pathParts[pathParts.length - 1] } const config = { name: agentName, ...md.data, prompt: md.content.trim(), } const parsed = Agent.safeParse(config) if (parsed.success) { result[config.name] = parsed.data continue } throw new InvalidError({ path: item }, { cause: parsed.error }) } return result } const MODE_GLOB = new Bun.Glob("mode/*.md") async function loadMode(dir: string) { const result: Record = {} for await (const item of MODE_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { const md = await ConfigMarkdown.parse(item) if (!md.data) continue const config = { name: path.basename(item, ".md"), ...md.data, prompt: md.content.trim(), } const parsed = Agent.safeParse(config) if (parsed.success) { result[config.name] = { ...parsed.data, mode: "primary" as const, } continue } } return result } const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}") async function loadPlugin(dir: string) { const plugins: string[] = [] for await (const item of PLUGIN_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { plugins.push(pathToFileURL(item).href) } return plugins } export const McpLocal = z .object({ type: z.literal("local").describe("Type of MCP server connection"), command: z.string().array().describe("Command and arguments to run the MCP server"), environment: z .record(z.string(), z.string()) .optional() .describe("Environment variables to set when running the MCP server"), enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), timeout: z .number() .int() .positive() .optional() .describe( "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", ), }) .strict() .meta({ ref: "McpLocalConfig", }) export const McpOAuth = z .object({ clientId: z .string() .optional() .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), }) .strict() .meta({ ref: "McpOAuthConfig", }) export type McpOAuth = z.infer export const McpRemote = z .object({ type: z.literal("remote").describe("Type of MCP server connection"), url: z.string().describe("URL of the remote MCP server"), enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), oauth: z .union([McpOAuth, z.literal(false)]) .optional() .describe( "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", ), timeout: z .number() .int() .positive() .optional() .describe( "Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified.", ), }) .strict() .meta({ ref: "McpRemoteConfig", }) export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export type Mcp = z.infer export const Permission = z.enum(["ask", "allow", "deny"]) export type Permission = z.infer export const Command = z.object({ template: z.string(), description: z.string().optional(), agent: z.string().optional(), model: z.string().optional(), subtask: z.boolean().optional(), }) export type Command = z.infer export const Agent = z .object({ model: z.string().optional(), temperature: z.number().optional(), top_p: z.number().optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), disable: z.boolean().optional(), description: z.string().optional().describe("Description of when to use the agent"), mode: z.enum(["subagent", "primary", "all"]).optional(), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format") .optional() .describe("Hex color code for the agent (e.g., #FF5733)"), maxSteps: z .number() .int() .positive() .optional() .describe("Maximum number of agentic iterations before forcing text-only response"), permission: z .object({ edit: Permission.optional(), bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), doom_loop: Permission.optional(), external_directory: Permission.optional(), }) .optional(), }) .catchall(z.any()) .meta({ ref: "AgentConfig", }) export type Agent = z.infer export const Keybinds = z .object({ leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), app_exit: z.string().optional().default("ctrl+c,ctrl+d,q").describe("Exit the application"), editor_open: z.string().optional().default("e").describe("Open external editor"), theme_list: z.string().optional().default("t").describe("List available themes"), sidebar_toggle: z.string().optional().default("b").describe("Toggle sidebar"), scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), status_view: z.string().optional().default("s").describe("View status"), session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), session_timeline: z.string().optional().default("g").describe("Show session timeline"), session_share: z.string().optional().default("none").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("c").describe("Compact the session"), messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"), messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), messages_half_page_down: z .string() .optional() .default("ctrl+alt+d") .describe("Scroll messages down by half page"), messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), messages_copy: z.string().optional().default("y").describe("Copy message"), messages_undo: z.string().optional().default("u").describe("Undo message"), messages_redo: z.string().optional().default("r").describe("Redo message"), messages_toggle_conceal: z .string() .optional() .default("h") .describe("Toggle code block concealment in messages"), tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), model_list: z.string().optional().default("m").describe("List available models"), model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), command_list: z.string().optional().default("ctrl+p").describe("List available commands"), agent_list: z.string().optional().default("a").describe("List agents"), agent_cycle: z.string().optional().default("tab").describe("Next agent"), agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), input_submit: z.string().optional().default("return").describe("Submit input"), input_newline: z .string() .optional() .default("shift+return,ctrl+return,alt+return,ctrl+j") .describe("Insert newline in input"), input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), input_select_down: z.string().optional().default("shift+down").describe("Select down in input"), input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"), input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"), input_select_line_home: z .string() .optional() .default("ctrl+shift+a") .describe("Select to start of line in input"), input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"), input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"), input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"), input_select_visual_line_home: z .string() .optional() .default("alt+shift+a") .describe("Select to start of visual line in input"), input_select_visual_line_end: z .string() .optional() .default("alt+shift+e") .describe("Select to end of visual line in input"), input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"), input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"), input_select_buffer_home: z .string() .optional() .default("shift+home") .describe("Select to start of buffer in input"), input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"), input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"), input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"), input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), input_word_forward: z .string() .optional() .default("alt+f,alt+right,ctrl+right") .describe("Move word forward in input"), input_word_backward: z .string() .optional() .default("alt+b,alt+left,ctrl+left") .describe("Move word backward in input"), input_select_word_forward: z .string() .optional() .default("alt+shift+f,alt+shift+right") .describe("Select word forward in input"), input_select_word_backward: z .string() .optional() .default("alt+shift+b,alt+shift+left") .describe("Select word backward in input"), input_delete_word_forward: z .string() .optional() .default("alt+d,alt+delete,ctrl+delete") .describe("Delete word forward in input"), input_delete_word_backward: z .string() .optional() .default("ctrl+w,ctrl+backspace,alt+backspace") .describe("Delete word backward in input"), history_previous: z.string().optional().default("up").describe("Previous history item"), history_next: z.string().optional().default("down").describe("Next history item"), session_child_cycle: z.string().optional().default("right").describe("Next child session"), session_child_cycle_reverse: z.string().optional().default("left").describe("Previous child session"), terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), }) .strict() .meta({ ref: "KeybindsConfig", }) export const TUI = z.object({ scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_acceleration: z .object({ enabled: z.boolean().describe("Enable scroll acceleration"), }) .optional() .describe("Scroll acceleration settings"), diff_style: z .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), }) export const Layout = z.enum(["auto", "stretch"]).meta({ ref: "LayoutConfig", }) export type Layout = z.infer export const Provider = ModelsDev.Provider.partial() .extend({ whitelist: z.array(z.string()).optional(), blacklist: z.array(z.string()).optional(), models: z.record(z.string(), ModelsDev.Model.partial()).optional(), options: z .object({ apiKey: z.string().optional(), baseURL: z.string().optional(), enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), timeout: z .union([ z .number() .int() .positive() .describe( "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", ), z.literal(false).describe("Disable timeout for this provider entirely."), ]) .optional() .describe( "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", ), }) .catchall(z.any()) .optional(), }) .strict() .meta({ ref: "ProviderConfig", }) export type Provider = z.infer export const Info = z .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), theme: z.string().optional().describe("Theme name to use for the interface"), keybinds: Keybinds.optional().describe("Custom keybind configurations"), tui: TUI.optional().describe("TUI specific settings"), command: z .record(z.string(), Command) .optional() .describe("Command configuration, see https://opencode.ai/docs/commands"), watcher: z .object({ ignore: z.array(z.string()).optional(), }) .optional(), plugin: z.string().array().optional(), snapshot: z.boolean().optional(), share: z .enum(["manual", "auto", "disabled"]) .optional() .describe( "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", ), autoshare: z .boolean() .optional() .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), autoupdate: z .union([z.boolean(), z.literal("notify")]) .optional() .describe( "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", ), disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"), enabled_providers: z .array(z.string()) .optional() .describe("When set, ONLY these providers will be enabled. All other providers will be ignored"), model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), small_model: z .string() .describe("Small model to use for tasks like title generation in the format of provider/model") .optional(), username: z .string() .optional() .describe("Custom username to display in conversations instead of system username"), mode: z .object({ build: Agent.optional(), plan: Agent.optional(), }) .catchall(Agent) .optional() .describe("@deprecated Use `agent` field instead."), agent: z .object({ // primary plan: Agent.optional(), build: Agent.optional(), // subagent general: Agent.optional(), explore: Agent.optional(), // specialized title: Agent.optional(), summary: Agent.optional(), compaction: Agent.optional(), }) .catchall(Agent) .optional() .describe("Agent configuration, see https://opencode.ai/docs/agent"), provider: z .record(z.string(), Provider) .optional() .describe("Custom provider configurations and model overrides"), mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"), formatter: z .union([ z.literal(false), z.record( z.string(), z.object({ disabled: z.boolean().optional(), command: z.array(z.string()).optional(), environment: z.record(z.string(), z.string()).optional(), extensions: z.array(z.string()).optional(), }), ), ]) .optional(), lsp: z .union([ z.literal(false), z.record( z.string(), z.union([ z.object({ disabled: z.literal(true), }), z.object({ command: z.array(z.string()), extensions: z.array(z.string()).optional(), disabled: z.boolean().optional(), env: z.record(z.string(), z.string()).optional(), initialization: z.record(z.string(), z.any()).optional(), }), ]), ), ]) .optional() .refine( (data) => { if (!data) return true if (typeof data === "boolean") return true const serverIds = new Set(Object.values(LSPServer).map((s) => s.id)) return Object.entries(data).every(([id, config]) => { if (config.disabled) return true if (serverIds.has(id)) return true return Boolean(config.extensions) }) }, { error: "For custom LSP servers, 'extensions' array is required.", }, ), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), permission: z .object({ edit: Permission.optional(), bash: z.union([Permission, z.record(z.string(), Permission)]).optional(), webfetch: Permission.optional(), doom_loop: Permission.optional(), external_directory: Permission.optional(), }) .optional(), tools: z.record(z.string(), z.boolean()).optional(), enterprise: z .object({ url: z.string().optional().describe("Enterprise URL"), }) .optional(), experimental: z .object({ hook: z .object({ file_edited: z .record( z.string(), z .object({ command: z.string().array(), environment: z.record(z.string(), z.string()).optional(), }) .array(), ) .optional(), session_completed: z .object({ command: z.string().array(), environment: z.record(z.string(), z.string()).optional(), }) .array() .optional(), }) .optional(), chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"), disable_paste_summary: z.boolean().optional(), batch_tool: z.boolean().optional().describe("Enable the batch tool"), openTelemetry: z .boolean() .optional() .describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"), primary_tools: z .array(z.string()) .optional() .describe("Tools that should only be available to primary agents."), continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), }) .optional(), }) .strict() .meta({ ref: "Config", }) export type Info = z.output export const global = lazy(async () => { let result: Info = pipe( {}, mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))), mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))), mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))), ) await import(path.join(Global.Path.config, "config"), { with: { type: "toml", }, }) .then(async (mod) => { const { provider, model, ...rest } = mod.default if (provider && model) result.model = `${provider}/${model}` result["$schema"] = "https://opencode.ai/config.json" result = mergeDeep(result, rest) await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) await fs.unlink(path.join(Global.Path.config, "config")) }) .catch(() => {}) return result }) async function loadFile(filepath: string): Promise { log.info("loading", { path: filepath }) let text = await Bun.file(filepath) .text() .catch((err) => { if (err.code === "ENOENT") return throw new JsonError({ path: filepath }, { cause: err }) }) if (!text) return {} return load(text, filepath) } async function load(text: string, configFilepath: string) { text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) const fileMatches = text.match(/\{file:[^}]+\}/g) if (fileMatches) { const configDir = path.dirname(configFilepath) const lines = text.split("\n") for (const match of fileMatches) { const lineIndex = lines.findIndex((line) => line.includes(match)) if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) { continue // Skip if line is commented } let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "") if (filePath.startsWith("~/")) { filePath = path.join(os.homedir(), filePath.slice(2)) } const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) const fileContent = ( await Bun.file(resolvedPath) .text() .catch((error) => { const errMsg = `bad file reference: "${match}"` if (error.code === "ENOENT") { throw new InvalidError( { path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist`, }, { cause: error }, ) } throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error }) }) ).trim() // escape newlines/quotes, strip outer quotes text = text.replace(match, JSON.stringify(fileContent).slice(1, -1)) } } const errors: JsoncParseError[] = [] const data = parseJsonc(text, errors, { allowTrailingComma: true }) if (errors.length) { const lines = text.split("\n") const errorDetails = errors .map((e) => { const beforeOffset = text.substring(0, e.offset).split("\n") const line = beforeOffset.length const column = beforeOffset[beforeOffset.length - 1].length + 1 const problemLine = lines[line - 1] const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}` if (!problemLine) return error return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^` }) .join("\n") throw new JsonError({ path: configFilepath, message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`, }) } const parsed = Info.safeParse(data) if (parsed.success) { if (!parsed.data.$schema) { parsed.data.$schema = "https://opencode.ai/config.json" await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)) } const data = parsed.data if (data.plugin) { for (let i = 0; i < data.plugin.length; i++) { const plugin = data.plugin[i] try { data.plugin[i] = import.meta.resolve!(plugin, configFilepath) } catch (err) {} } } return data } throw new InvalidError({ path: configFilepath, issues: parsed.error.issues, }) } export const JsonError = NamedError.create( "ConfigJsonError", z.object({ path: z.string(), message: z.string().optional(), }), ) export const ConfigDirectoryTypoError = NamedError.create( "ConfigDirectoryTypoError", z.object({ path: z.string(), dir: z.string(), suggestion: z.string(), }), ) export const InvalidError = NamedError.create( "ConfigInvalidError", z.object({ path: z.string(), issues: z.custom().optional(), message: z.string().optional(), }), ) export async function get() { return state().then((x) => x.config) } export async function update(config: Info) { const filepath = path.join(Instance.directory, "config.json") const existing = await loadFile(filepath) await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2)) await Instance.dispose() } export async function directories() { return state().then((x) => x.directories) } }