summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2026-04-16 13:29:00 -0400
committerDax Raad <[email protected]>2026-04-16 13:29:03 -0400
commit03e20e6ac125b6a792c567451c2f904c6ed6941c (patch)
tree41de61d176648186dc4c595cd78cf2de2276efe5
parentc5deeee8c7b2e5b3927d28958d2ceb9ebddeb256 (diff)
downloadopencode-03e20e6ac125b6a792c567451c2f904c6ed6941c.tar.gz
opencode-03e20e6ac125b6a792c567451c2f904c6ed6941c.zip
core: modularize config parsing to improve maintainability
Extract error handling, parsing logic, and variable substitution into dedicated modules. This reduces duplication between tui.json and opencode.json parsing and makes the config system easier to extend for future config formats.
-rw-r--r--packages/opencode/src/cli/cmd/tui/config/tui.ts47
-rw-r--r--packages/opencode/src/config/agent.ts2
-rw-r--r--packages/opencode/src/config/command.ts2
-rw-r--r--packages/opencode/src/config/config.ts144
-rw-r--r--packages/opencode/src/config/error.ts21
-rw-r--r--packages/opencode/src/config/index.ts3
-rw-r--r--packages/opencode/src/config/managed.ts16
-rw-r--r--packages/opencode/src/config/parse.ts80
-rw-r--r--packages/opencode/src/config/paths.ts123
-rw-r--r--packages/opencode/src/config/variable.ts84
-rw-r--r--packages/opencode/test/config/config.test.ts86
11 files changed, 335 insertions, 273 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts
index 6f2c161fb..e8eb9ff5d 100644
--- a/packages/opencode/src/cli/cmd/tui/config/tui.ts
+++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts
@@ -1,6 +1,7 @@
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
+import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
@@ -68,6 +69,14 @@ export namespace TuiConfig {
}
}
+ async function resolvePlugins(config: Info, configFilepath: string) {
+ if (!config.plugin) return config
+ for (let i = 0; i < config.plugin.length; i++) {
+ config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
+ }
+ return config
+ }
+
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
@@ -183,26 +192,22 @@ export namespace TuiConfig {
}
async function load(text: string, configFilepath: string): Promise<Info> {
- const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
- if (!isRecord(raw)) return {}
-
- // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
- // (mirroring the old opencode.json shape) still get their settings applied.
- const normalized = normalize(raw)
-
- const parsed = Info.safeParse(normalized)
- if (!parsed.success) {
- log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
- return {}
- }
-
- const data = parsed.data
- if (data.plugin) {
- for (let i = 0; i < data.plugin.length; i++) {
- data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
- }
- }
-
- return data
+ return ConfigParse.load(Info, text, {
+ type: "path",
+ path: configFilepath,
+ missing: "empty",
+ normalize: (data) => {
+ if (!isRecord(data)) return {}
+
+ // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
+ // (mirroring the old opencode.json shape) still get their settings applied.
+ return normalize(data)
+ },
+ })
+ .then((data) => resolvePlugins(data, configFilepath))
+ .catch((error) => {
+ log.warn("invalid tui config", { path: configFilepath, error })
+ return {}
+ })
}
}
diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts
index 3819368e8..f754f009d 100644
--- a/packages/opencode/src/config/agent.ts
+++ b/packages/opencode/src/config/agent.ts
@@ -6,9 +6,9 @@ import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
+import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
-import { InvalidError } from "./paths"
import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })
diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts
index 5606bdd4c..979925056 100644
--- a/packages/opencode/src/config/command.ts
+++ b/packages/opencode/src/config/command.ts
@@ -6,9 +6,9 @@ import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
+import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
-import { InvalidError } from "./paths"
const log = Log.create({ service: "config" })
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index ed3be8808..6b6d74ed8 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -10,13 +10,7 @@ import { NamedError } from "@opencode-ai/shared/util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
-import {
- type ParseError as JsoncParseError,
- applyEdits,
- modify,
- parse as parseJsonc,
- printParseErrorCode,
-} from "jsonc-parser"
+import { applyEdits, modify } from "jsonc-parser"
import { Instance, type InstanceContext } from "../project/instance"
import * as LSPServer from "../lsp/server"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
@@ -25,6 +19,7 @@ import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
+import { InvalidError, JsonError } from "./error"
import * as ConfigPaths from "./paths"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -39,6 +34,7 @@ import { ConfigModelID } from "./model-id"
import { ConfigPlugin } from "./plugin"
import { ConfigManaged } from "./managed"
import { ConfigCommand } from "./command"
+import { ConfigParse } from "./parse"
import { ConfigPermission } from "./permission"
import { ConfigProvider } from "./provider"
import { ConfigSkills } from "./skills"
@@ -54,6 +50,28 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
return merged
}
+function normalizeLoadedConfig(data: unknown, source: string) {
+ if (!isRecord(data)) return data
+ const copy = { ...data }
+ const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
+ if (!hadLegacy) return copy
+ delete copy.theme
+ delete copy.keybinds
+ delete copy.tui
+ log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
+ return copy
+}
+
+async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
+ if (!config.plugin) return config
+ for (let i = 0; i < config.plugin.length; i++) {
+ // Normalize path-like plugin specs while we still know which config file declared them.
+ // This prevents `./plugin.ts` from being reinterpreted relative to some later merge location.
+ config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], filepath)
+ }
+ return config
+}
+
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -325,42 +343,6 @@ function writable(info: Info) {
return next
}
-export function parseConfig(text: string, filepath: string): Info {
- 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: filepath,
- message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
- })
- }
-
- const parsed = Info.safeParse(data)
- if (parsed.success) return parsed.data
-
- throw new InvalidError({
- path: filepath,
- issues: parsed.error.issues,
- })
-}
-
-export const { JsonError, InvalidError } = ConfigPaths
-
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
z.object({
@@ -393,48 +375,31 @@ export const layer = Layer.effect(
text: string,
options: { path: string } | { dir: string; source: string },
) {
- const original = text
- const source = "path" in options ? options.path : options.source
- const isFile = "path" in options
+ if (!("path" in options)) {
+ return yield* Effect.promise(() =>
+ ConfigParse.load(Info, text, {
+ type: "virtual",
+ dir: options.dir,
+ source: options.source,
+ normalize: normalizeLoadedConfig,
+ }),
+ )
+ }
+
const data = yield* Effect.promise(() =>
- ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
+ ConfigParse.load(Info, text, {
+ type: "path",
+ path: options.path,
+ normalize: normalizeLoadedConfig,
+ }),
)
-
- const normalized = (() => {
- if (!data || typeof data !== "object" || Array.isArray(data)) return data
- const copy = { ...(data as Record<string, unknown>) }
- const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
- if (!hadLegacy) return copy
- delete copy.theme
- delete copy.keybinds
- delete copy.tui
- log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
- return copy
- })()
-
- const parsed = Info.safeParse(normalized)
- if (parsed.success) {
- if (!parsed.data.$schema && isFile) {
- parsed.data.$schema = "https://opencode.ai/config.json"
- const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
- yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
- }
- const data = parsed.data
- if (data.plugin && isFile) {
- const list = data.plugin
- for (let i = 0; i < list.length; i++) {
- // Normalize path-like plugin specs while we still know which config file declared them.
- // This prevents `./plugin.ts` from being reinterpreted relative to some later merge location.
- list[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(list[i], options.path))
- }
- }
- return data
+ yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
+ if (!data.$schema) {
+ data.$schema = "https://opencode.ai/config.json"
+ const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
+ yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
-
- throw new InvalidError({
- path: source,
- issues: parsed.error.issues,
- })
+ return data
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
@@ -692,7 +657,16 @@ export const layer = Layer.effect(
}
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
- result = mergeConfigConcatArrays(result, yield* Effect.promise(() => ConfigManaged.readManagedPreferences()))
+ const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
+ if (managed) {
+ result = mergeConfigConcatArrays(
+ result,
+ yield* loadConfig(managed.text, {
+ dir: path.dirname(managed.source),
+ source: managed.source,
+ }),
+ )
+ }
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
@@ -803,13 +777,13 @@ export const layer = Layer.effect(
let next: Info
if (!file.endsWith(".jsonc")) {
- const existing = parseConfig(before, file)
+ const existing = ConfigParse.parse(Info, before, file)
const merged = mergeDeep(writable(existing), input)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, input)
- next = parseConfig(updated, file)
+ next = ConfigParse.parse(Info, updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
diff --git a/packages/opencode/src/config/error.ts b/packages/opencode/src/config/error.ts
new file mode 100644
index 000000000..06f549fd8
--- /dev/null
+++ b/packages/opencode/src/config/error.ts
@@ -0,0 +1,21 @@
+export * as ConfigError from "./error"
+
+import z from "zod"
+import { NamedError } from "@opencode-ai/shared/util/error"
+
+export const JsonError = NamedError.create(
+ "ConfigJsonError",
+ z.object({
+ path: z.string(),
+ message: z.string().optional(),
+ }),
+)
+
+export const InvalidError = NamedError.create(
+ "ConfigInvalidError",
+ z.object({
+ path: z.string(),
+ issues: z.custom<z.core.$ZodIssue[]>().optional(),
+ message: z.string().optional(),
+ }),
+)
diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts
index 37665d8c6..c4a1c608b 100644
--- a/packages/opencode/src/config/index.ts
+++ b/packages/opencode/src/config/index.ts
@@ -1,10 +1,13 @@
export * as Config from "./config"
export * as ConfigAgent from "./agent"
export * as ConfigCommand from "./command"
+export * as ConfigError from "./error"
+export * as ConfigVariable from "./variable"
export { ConfigManaged } from "./managed"
export * as ConfigMarkdown from "./markdown"
export * as ConfigMCP from "./mcp"
export { ConfigModelID } from "./model-id"
+export * as ConfigParse from "./parse"
export * as ConfigPermission from "./permission"
export * as ConfigPaths from "./paths"
export * as ConfigProvider from "./provider"
diff --git a/packages/opencode/src/config/managed.ts b/packages/opencode/src/config/managed.ts
index 61c535185..19b048ffc 100644
--- a/packages/opencode/src/config/managed.ts
+++ b/packages/opencode/src/config/managed.ts
@@ -1,7 +1,6 @@
import { existsSync } from "fs"
import os from "os"
import path from "path"
-import { type Info, parseConfig } from "./config"
import { Log, Process } from "../util"
const log = Log.create({ service: "config" })
@@ -33,16 +32,16 @@ function managedConfigDir() {
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
}
-function parseManagedPlist(json: string, source: string): Info {
+function parseManagedPlist(json: string): string {
const raw = JSON.parse(json)
for (const key of Object.keys(raw)) {
if (PLIST_META.has(key)) delete raw[key]
}
- return parseConfig(JSON.stringify(raw), source)
+ return JSON.stringify(raw)
}
-async function readManagedPreferences(): Promise<Info> {
- if (process.platform !== "darwin") return {}
+async function readManagedPreferences() {
+ if (process.platform !== "darwin") return
const user = os.userInfo().username
const paths = [
@@ -58,10 +57,13 @@ async function readManagedPreferences(): Promise<Info> {
log.warn("failed to convert managed preferences plist", { path: plist })
continue
}
- return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`)
+ return {
+ source: `mobileconfig:${plist}`,
+ text: parseManagedPlist(result.stdout.toString()),
+ }
}
- return {}
+ return
}
export const ConfigManaged = {
diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts
new file mode 100644
index 000000000..65cc48385
--- /dev/null
+++ b/packages/opencode/src/config/parse.ts
@@ -0,0 +1,80 @@
+export * as ConfigParse from "./parse"
+
+import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
+import z from "zod"
+import { ConfigVariable } from "./variable"
+import { InvalidError, JsonError } from "./error"
+
+type Schema<T> = z.ZodType<T>
+type VariableMode = "error" | "empty"
+
+export type LoadOptions =
+ | {
+ type: "path"
+ path: string
+ missing?: VariableMode
+ normalize?: (data: unknown, source: string) => unknown
+ }
+ | {
+ type: "virtual"
+ dir: string
+ source: string
+ missing?: VariableMode
+ normalize?: (data: unknown, source: string) => unknown
+ }
+
+function issues(text: string, errors: JsoncParseError[]) {
+ const lines = text.split("\n")
+ return 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")
+}
+
+export function parse<T>(schema: Schema<T>, text: string, filepath: string): T {
+ const errors: JsoncParseError[] = []
+ const data = parseJsonc(text, errors, { allowTrailingComma: true })
+ if (errors.length) {
+ throw new JsonError({
+ path: filepath,
+ message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues(text, errors)}\n--- End ---`,
+ })
+ }
+
+ const parsed = schema.safeParse(data)
+ if (parsed.success) return parsed.data
+
+ throw new InvalidError({
+ path: filepath,
+ issues: parsed.error.issues,
+ })
+}
+
+export async function load<T>(schema: Schema<T>, text: string, options: LoadOptions): Promise<T> {
+ const source = options.type === "path" ? options.path : options.source
+ const expanded = await ConfigVariable.substitute(
+ text,
+ options.type === "path" ? { type: "path", path: options.path } : options,
+ options.missing,
+ )
+ const data = parse(z.unknown(), expanded, source)
+ const normalized = options.normalize ? options.normalize(data, source) : data
+ const parsed = schema.safeParse(normalized)
+ if (!parsed.success) {
+ throw new InvalidError({
+ path: source,
+ issues: parsed.error.issues,
+ })
+ }
+
+ return parsed.data
+}
diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts
index fabd3fd5f..faf585d9b 100644
--- a/packages/opencode/src/config/paths.ts
+++ b/packages/opencode/src/config/paths.ts
@@ -1,12 +1,9 @@
import path from "path"
-import os from "os"
-import z from "zod"
-import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
-import { NamedError } from "@opencode-ai/shared/util/error"
import { Filesystem } from "@/util"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { unique } from "remeda"
+import { JsonError } from "./error"
export async function projectFiles(name: string, directory: string, worktree?: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
@@ -39,23 +36,6 @@ export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
}
-export const JsonError = NamedError.create(
- "ConfigJsonError",
- z.object({
- path: z.string(),
- message: z.string().optional(),
- }),
-)
-
-export const InvalidError = NamedError.create(
- "ConfigInvalidError",
- z.object({
- path: z.string(),
- issues: z.custom<z.core.$ZodIssue[]>().optional(),
- message: z.string().optional(),
- }),
-)
-
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
@@ -63,104 +43,3 @@ export async function readFile(filepath: string) {
throw new JsonError({ path: filepath }, { cause: err })
})
}
-
-type ParseSource = string | { source: string; dir: string }
-
-function source(input: ParseSource) {
- return typeof input === "string" ? input : input.source
-}
-
-function dir(input: ParseSource) {
- return typeof input === "string" ? path.dirname(input) : input.dir
-}
-
-/** Apply {env:VAR} and {file:path} substitutions to config text. */
-async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
- text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
- return process.env[varName] || ""
- })
-
- const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
- if (!fileMatches.length) return text
-
- const configDir = dir(input)
- const configSource = source(input)
- let out = ""
- let cursor = 0
-
- for (const match of fileMatches) {
- const token = match[0]
- const index = match.index!
- out += text.slice(cursor, index)
-
- const lineStart = text.lastIndexOf("\n", index - 1) + 1
- const prefix = text.slice(lineStart, index).trimStart()
- if (prefix.startsWith("//")) {
- out += token
- cursor = index + token.length
- continue
- }
-
- let filePath = token.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 Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
- if (missing === "empty") return ""
-
- const errMsg = `bad file reference: "${token}"`
- if (error.code === "ENOENT") {
- throw new InvalidError(
- {
- path: configSource,
- message: errMsg + ` ${resolvedPath} does not exist`,
- },
- { cause: error },
- )
- }
- throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
- })
- ).trim()
-
- out += JSON.stringify(fileContent).slice(1, -1)
- cursor = index + token.length
- }
-
- out += text.slice(cursor)
- return out
-}
-
-/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
-export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
- const configSource = source(input)
- text = await substitute(text, input, missing)
-
- 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: configSource,
- message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
- })
- }
-
- return data
-}
diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts
new file mode 100644
index 000000000..e016e33a2
--- /dev/null
+++ b/packages/opencode/src/config/variable.ts
@@ -0,0 +1,84 @@
+export * as ConfigVariable from "./variable"
+
+import path from "path"
+import os from "os"
+import { Filesystem } from "@/util"
+import { InvalidError } from "./error"
+
+type ParseSource =
+ | {
+ type: "path"
+ path: string
+ }
+ | {
+ type: "virtual"
+ source: string
+ dir: string
+ }
+
+function source(input: ParseSource) {
+ return input.type === "path" ? input.path : input.source
+}
+
+function dir(input: ParseSource) {
+ return input.type === "path" ? path.dirname(input.path) : input.dir
+}
+
+/** Apply {env:VAR} and {file:path} substitutions to config text. */
+export async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
+ text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
+ return process.env[varName] || ""
+ })
+
+ const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
+ if (!fileMatches.length) return text
+
+ const configDir = dir(input)
+ const configSource = source(input)
+ let out = ""
+ let cursor = 0
+
+ for (const match of fileMatches) {
+ const token = match[0]
+ const index = match.index!
+ out += text.slice(cursor, index)
+
+ const lineStart = text.lastIndexOf("\n", index - 1) + 1
+ const prefix = text.slice(lineStart, index).trimStart()
+ if (prefix.startsWith("//")) {
+ out += token
+ cursor = index + token.length
+ continue
+ }
+
+ let filePath = token.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 Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
+ if (missing === "empty") return ""
+
+ const errMsg = `bad file reference: "${token}"`
+ if (error.code === "ENOENT") {
+ throw new InvalidError(
+ {
+ path: configSource,
+ message: errMsg + ` ${resolvedPath} does not exist`,
+ },
+ { cause: error },
+ )
+ }
+ throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
+ })
+ ).trim()
+
+ out += JSON.stringify(fileContent).slice(1, -1)
+ cursor = index + token.length
+ }
+
+ out += text.slice(cursor)
+ return out
+}
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 21d6e3e93..c41f395e5 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config, ConfigManaged } from "../../src/config"
+import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { Instance } from "../../src/project/instance"
@@ -2211,17 +2212,20 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
// parseManagedPlist unit tests — pure function, no OS interaction
test("parseManagedPlist strips MDM metadata keys", async () => {
- const config = await ConfigManaged.parseManagedPlist(
- JSON.stringify({
- PayloadDisplayName: "OpenCode Managed",
- PayloadIdentifier: "ai.opencode.managed.test",
- PayloadType: "ai.opencode.managed",
- PayloadUUID: "AAAA-BBBB-CCCC",
- PayloadVersion: 1,
- _manualProfile: true,
- share: "disabled",
- model: "mdm/model",
- }),
+ const config = ConfigParse.parse(
+ Config.Info,
+ await ConfigManaged.parseManagedPlist(
+ JSON.stringify({
+ PayloadDisplayName: "OpenCode Managed",
+ PayloadIdentifier: "ai.opencode.managed.test",
+ PayloadType: "ai.opencode.managed",
+ PayloadUUID: "AAAA-BBBB-CCCC",
+ PayloadVersion: 1,
+ _manualProfile: true,
+ share: "disabled",
+ model: "mdm/model",
+ }),
+ ),
"test:mobileconfig",
)
expect(config.share).toBe("disabled")
@@ -2233,12 +2237,15 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
})
test("parseManagedPlist parses server settings", async () => {
- const config = await ConfigManaged.parseManagedPlist(
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- server: { hostname: "127.0.0.1", mdns: false },
- autoupdate: true,
- }),
+ const config = ConfigParse.parse(
+ Config.Info,
+ await ConfigManaged.parseManagedPlist(
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ server: { hostname: "127.0.0.1", mdns: false },
+ autoupdate: true,
+ }),
+ ),
"test:mobileconfig",
)
expect(config.server?.hostname).toBe("127.0.0.1")
@@ -2247,18 +2254,21 @@ test("parseManagedPlist parses server settings", async () => {
})
test("parseManagedPlist parses permission rules", async () => {
- const config = await ConfigManaged.parseManagedPlist(
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- permission: {
- "*": "ask",
- bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
- grep: "allow",
- glob: "allow",
- webfetch: "ask",
- "~/.ssh/*": "deny",
- },
- }),
+ const config = ConfigParse.parse(
+ Config.Info,
+ await ConfigManaged.parseManagedPlist(
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ permission: {
+ "*": "ask",
+ bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
+ grep: "allow",
+ glob: "allow",
+ webfetch: "ask",
+ "~/.ssh/*": "deny",
+ },
+ }),
+ ),
"test:mobileconfig",
)
expect(config.permission?.["*"]).toBe("ask")
@@ -2271,19 +2281,23 @@ test("parseManagedPlist parses permission rules", async () => {
})
test("parseManagedPlist parses enabled_providers", async () => {
- const config = await ConfigManaged.parseManagedPlist(
- JSON.stringify({
- $schema: "https://opencode.ai/config.json",
- enabled_providers: ["anthropic", "google"],
- }),
+ const config = ConfigParse.parse(
+ Config.Info,
+ await ConfigManaged.parseManagedPlist(
+ JSON.stringify({
+ $schema: "https://opencode.ai/config.json",
+ enabled_providers: ["anthropic", "google"],
+ }),
+ ),
"test:mobileconfig",
)
expect(config.enabled_providers).toEqual(["anthropic", "google"])
})
test("parseManagedPlist handles empty config", async () => {
- const config = await ConfigManaged.parseManagedPlist(
- JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
+ const config = ConfigParse.parse(
+ Config.Info,
+ await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
"test:mobileconfig",
)
expect(config.$schema).toBe("https://opencode.ai/config.json")