summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-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")