配置系统
九层配置来源与合并顺序、JSONC 与变量替换、Markdown 定义的 agent 与命令、独立的 tui.json,以及通往 V2 的兼容层。
现役(V1)配置的加载逻辑在 packages/opencode/src/config/,字段 schema 在 packages/core/src/v1/config/,新内核的版本是 packages/core/src/config.ts。先看「写在哪、谁覆盖谁」,再看实现里几处值得留意的设计。
九层来源
loadInstanceState()(packages/opencode/src/config/config.ts:328)按下表顺序深度合并,后者覆盖前者:
| 顺序 | 来源 | 位置 |
|---|---|---|
| 1 | 远端 well-known | 每个 wellknown 类型认证凭据对应的 <url>/.well-known/opencode |
| 2 | 全局配置 | 全局目录(默认 ~/.config/opencode/)下依次读 config.json、opencode.json、opencode.jsonc |
| 3 | 自定义文件 | 环境变量 OPENCODE_CONFIG 指向的文件 |
| 4 | 项目配置 | 从 git 工作树根到当前目录逐层的 opencode.json(c),越近越优先 |
| 5 | .opencode 目录 | 各级 .opencode/、~/.opencode/、OPENCODE_CONFIG_DIR 中的 opencode.json(c) |
| 6 | 内联配置 | 环境变量 OPENCODE_CONFIG_CONTENT 里的整段 JSON |
| 7 | 控制台组织 | 登录控制台并选定组织时拉取的 <url>/api/config |
| 8 | 托管目录 | macOS /Library/Application Support/opencode,Windows %ProgramData%\opencode,其他 /etc/opencode |
| 9 | macOS MDM | ai.opencode.managed 偏好域的 plist,注释写着“override everything” |
几个细节:
- 同一目录下
.jsonc晚于.json合并,并存时.jsonc胜出; - 合并用 remeda 的
mergeDeep,只有instructions拼接去重;plugin按包名(本地文件按 URL)去重、后声明者胜,并记下来源文件与作用域(派生字段plugin_origins); OPENCODE_DISABLE_PROJECT_CONFIG同时跳过第 4 层与项目内的.opencode;- 第 5 层的顺序容易踩坑:
ConfigPaths.directories()(paths.ts:23)向上收集.opencode时「近者在前」,却没有像files()那样toReversed(),所以多级.opencode并存时外层反而覆盖内层。新内核扳正了这一点(见文末)。
合并之后的修正
config.ts:550-598 还做了几步语义修正:废弃的 mode 并入 agent 并强制为 primary;OPENCODE_PERMISSION 深合并进 permission;旧的 tools 开关表翻译成权限,write、edit、patch 折叠为 edit,显式的 permission 优先;autoshare: true 等价于 share: "auto"。V2 设计文档称这种翻译为“lossy compatibility input”,决定不再保留。
JSONC:宽进严出
ConfigParse.jsonc() 基于 jsonc-parser,允许注释与尾逗号,报错带行号、列号和指向出错处的 ^。ConfigParse.schema() 解码时用 errors: "all" 与 onExcessProperty: "ignore":不认识的字段静默忽略,已知字段写错类型直接报错。内置 skill 的注释说得直白——“opencode hard-fails on invalid config”。
变量替换:先换文本,再做解析
ConfigVariable.substitute() 在 JSONC 解析之前处理原始文本:
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export async function substitute(input: SubstituteInput) {
const missing = input.missing ?? "error"
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return (input.env?.[varName] ?? process.env[varName]) || ""
})(packages/opencode/src/config/variable.ts:33-38)
{env:VAR}缺失时替换为空串,不报错;{file:path}相对声明它的配置文件所在目录解析,支持~/,内容trim()后按 JSON 字符串转义嵌入;文件不存在报bad file reference(TUI 配置改为空串);整行//注释里的{file:...}会被跳过。
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CONTRIBUTING.md"],
"permission": { "bash": { "*": "ask", "git status": "allow" } },
"mcp": {
"docs": {
"type": "remote",
"url": "https://docs.example.com/mcp",
"headers": { "Authorization": "Bearer {env:DOCS_TOKEN}" }
}
}
}$schema 与 JSON Schema
$schema 指向的 https://opencode.ai/config.json 由 packages/opencode/script/schema.ts 从 ConfigV1.Info 生成,附加 allowComments 与 allowTrailingCommas;tui.json 的 schema 由 TuiConfig.Info 生成。加载器还会主动维护它:全局配置不存在时先写一个只含 $schema 的文件,读到缺 $schema 的配置就改写原文件补上(config.ts:245-249)。V2 设计文档的态度相反:“loading config must not insert it or create files for it”。
Markdown 定义的 agent、命令与更多
每个配置目录(全局目录、各级 .opencode/、~/.opencode/、OPENCODE_CONFIG_DIR)还会按约定子目录扫描:
| 模式 | 产物 |
|---|---|
{agent,agents}/**/*.md | agent,frontmatter 是字段,正文是 prompt |
{mode,modes}/*.md | 旧式 mode,强制为 primary agent |
{command,commands}/**/*.md | 命令,正文是 template |
{skill,skills}/**/SKILL.md | skill |
{plugin,plugins}/*.{ts,js} 与 {tool,tools}/*.{js,ts} | 本地插件与自定义工具 |
名字取自相对路径,agents/review/security.md 得到 review/security。frontmatter 解析失败时还有一次宽松重试(packages/core/src/config/markdown.ts:20-21):
Other coding agents accept unquoted colons in frontmatter values. Retry those values as YAML block scalars so existing config files keep working.
opencode 包里的同名文件点名了 Claude Code。加载器还会在每个配置目录后台安装匹配版本的 @opencode-ai/plugin 并写好 .gitignore,让本地插件能直接 import 到 API。
TUI 配置单独成文件
终端界面相关设置拆进了 tui.json(packages/opencode/src/config/tui.ts),顺序与服务端平行:全局 tui.json(c) → OPENCODE_TUI_CONFIG → 项目各级 tui.json(c) → .opencode 目录;字段有 theme、keybinds、plugin、plugin_enabled、attention、diff_style、mouse 等。
容错策略与服务端相反:“every broken-config path degrades gracefully rather than crashing TUI startup”,坏文件记警告后跳过。老版本写在 opencode.json 里的 theme、keybinds、tui 由 tui-migrate.ts 按目录迁移到 tui.json,原文件先备份为 <原文件名>.tui-migration.bak。
生效时机:失效与重建
配置按实例(目录)缓存在 InstanceState 中,全局配置另有一份永不过期的缓存。现役代码里找不到监视配置文件的逻辑,改动靠「清缓存 + 丢弃实例」生效:通过 HTTP 接口改全局配置(updateGlobal,.jsonc 以补丁方式写回)后丢弃所有实例;改项目配置(update)则等响应发出后丢弃当前实例;TUI 进程收到 SIGUSR2 时让 worker 执行 reload。下一次请求到来时按新配置重建。opencode debug config 可以打印合并后的最终结果。
通往 V2:兼容层与新内核
V2 设计(specs/v2/config.md)逐组评审了 V1 字段:集合改复数(agents、permissions、plugins、providers),command 移除、改由 skills 承担,MCP 服务器收进 mcp.servers 并以 disabled 取代 enabled,权限改为有序的 { action, resource, effect } 规则数组。
兼容层 packages/opencode/src/config/v2-compat.ts 让 V1 读懂一部分 V2 写法:ConfigV2Compat.lower() 在校验前把 agents、commands、mcp.servers 等降级为 V1 字段,产出 invalid、unsupported、conflict(新旧并存时保留旧值)三类诊断;唯独 V2 的 permissions 直接报错,提示 run opencode2。
新内核 packages/core/src/config.ts 只认 opencode.json(c),只在打开位置时读一次,结果是按优先级排好的 Document / Directory 列表,而非合并好的大表。.opencode 的顺序在这里被显式扳正:
const directories = [
globalDirectory,
...discovered
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
]
// A config closer to the opened directory should win over one higher up.
// Search starts nearby, so reverse the results before applying them.
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()(packages/core/src/config.ts:186-195)
下一篇:Agent、命令与 Skills——七个内置 agent 的权限设计、命令模板的展开顺序与 SKILL.md 的发现规则。