Config 源码解读
模块概述
Config 模块管理 OpenCode 的所有配置——从模型选择到权限规则、从 MCP 服务器到快捷键绑定。它采用 Zod Schema 驱动的类型系统,配合六层配置源的优先级合并,让项目级共享配置与用户个人偏好有序共存。
核心设计选择:
- Zod Schema 全覆盖:所有类型定义使用 Zod,运行时可验证、编译时类型安全
- 六层配置优先级:内置默认值 → 全局配置 → 环境变量文件 → 项目配置 → .opencode/ → 环境变量内容
- JSONC + 变量替换:支持注释的 JSON 格式,配合
env:VAR/file:path占位符语法运行时替换 - mergeDeep + 数组拼接:基于 remeda 的深度合并,
plugins/instructions等数组字段拼接而非覆盖 - 全量热重载:配置变更时
Instance.dispose()→Instance.state(),销毁-重建确保一致性
Config 同样基于 Effect.ts 架构,通过 Config.Service 暴露服务接口,InstanceState 管理每个项目的配置生命周期。
关键文件
| 文件路径 | 行数 | 职责 |
|---|---|---|
src/config/config.ts | ~2600 | 模块主文件:所有 Zod Schema 定义、六层加载逻辑、mergeDeep 合并、变量替换、Service 定义 |
src/config/paths.ts | ~30 | ConfigPaths 辅助路径:全局/项目级配置文件的路径计算 |
src/config/markdown.ts | ~20 | ConfigMarkdown 类型:Markdown 渲染相关配置 |
src/config/tui-schema.ts | ~50 | TUI 配置 Schema:快捷键绑定、主题、布局等 UI 配置的 Zod 定义 |
src/config/tui.ts | ~100 | TUI 配置逻辑:TUI 相关配置的加载与处理 |
src/config/tui-migrate.ts | ~60 | TUI 配置迁移:旧版本配置格式到新格式的自动迁移 |
src/config/console-state.ts | ~20 | ConsoleState 类型:控制台输出状态管理 |
.opencode/config.json | — | 项目级配置文件(可提交 Git) |
~/.config/opencode/config.json | — | 用户全局配置 |
类型体系
Permission Schema — 14+ 种操作类型
权限操作类型是 Config 中最核心的 Schema 之一,定义了 Agent 可以执行的所有操作类别:
// 权限操作的三态枚举
const PermissionAction = z.enum(["ask", "allow", "deny"]);
// 权限规则:14+ 种操作的独立控制
const Permission = z.object({
read: PermissionAction.default("allow"), // 读取文件
edit: PermissionAction.default("ask"), // 编辑文件
bash: PermissionAction.default("ask"), // 执行 Shell 命令
glob: PermissionAction.default("allow"), // 文件模式搜索
grep: PermissionAction.default("allow"), // 文件内容搜索
list: PermissionAction.default("allow"), // 目录列表
task: PermissionAction.default("allow"), // 子任务执行
external_directory: PermissionAction.default("ask"), // 访问项目外目录
todowrite: PermissionAction.default("ask"), // 写入 TODO
todoread: PermissionAction.default("allow"), // 读取 TODO
question: PermissionAction.default("ask"), // 向用户提问
webfetch: PermissionAction.default("ask"), // 网络请求
websearch: PermissionAction.default("ask"), // 网络搜索
codesearch: PermissionAction.default("ask"), // 代码搜索
lsp: PermissionAction.default("ask"), // LSP 操作
doom_loop: PermissionAction.default("ask"), // Doom Loop 确认
}).catchall(PermissionAction.default("ask"));
设计意图:默认策略是”读操作 allow、写操作 ask”,兼顾效率和安全。catchall 确保新增工具不会意外放行——任何未列出的操作类型默认走 ask 流程。
Provider Schema — 模型供应商配置
const Provider = z.object({
apiKey: z.string().optional(), // API 密钥(支持 {env:} 替换)
baseURL: z.string().optional(), // 自定义 API 端点
disabled: z.boolean().optional(), // 是否禁用该 Provider
models: z.record(z.string(), z.object({
// 模型级别的覆盖配置
disabled: z.boolean().optional(),
// ...
})).optional(),
});
Mcp Schema — MCP 服务器配置
const Mcp = z.discriminatedUnion("type", [
z.object({
type: z.literal("local"), // 本地 MCP(stdio 模式)
command: z.string(), // 启动命令,如 "npx"
args: z.array(z.string()).optional(), // 命令参数
env: z.record(z.string()).optional(), // 环境变量
disabled: z.boolean().optional(),
}),
z.object({
type: z.literal("remote"), // 远程 MCP(SSE 模式)
url: z.string(), // SSE 端点 URL
headers: z.record(z.string()).optional(),
disabled: z.boolean().optional(),
}),
]);
discriminatedUnion 让两种 MCP 模式的类型安全共存——TypeScript 能根据 type 字段自动收窄剩余字段。
Agent Schema — Agent 自定义配置
const Agent = z.object({
model: z.string().optional(), // 绑定模型
prompt: z.string().optional(), // 自定义系统提示
permission: Permission.optional(), // 覆盖权限规则
temperature: z.number().optional(), // 温度参数
topP: z.number().optional(), // top_p 参数
disabled: z.boolean().optional(), // 是否禁用
steps: z.number().int().positive().optional(), // 最大步数
});
Command Schema — 自定义命令
const Command = z.object({
description: z.string().optional(), // 命令描述
agent: z.string().optional(), // 指定执行 Agent
template: z.string(), // 命令模板文本
subtask: z.boolean().optional(), // 是否子任务模式
});
Info — 顶层聚合 Schema
所有子配置汇聚到顶层 Info Schema:
const Info = z.object({
model: ModelSchema.optional(), // 默认模型选择
provider: z.record(Provider).optional(), // Provider 配置字典
agent: z.record(Agent).optional(), // 自定义 Agent 配置
mcp: z.record(Mcp).optional(), // MCP 服务器配置
permission: Permission.optional(), // 全局权限规则
command: z.array(Command).optional(), // 自定义命令列表
keybinds: Keybinds.optional(), // 快捷键绑定
tui: TUI.optional(), // TUI 配置
server: Server.optional(), // 服务器配置
// ... 其他字段
});
每个子 Schema 都使用 .default() 提供回退值,确保任何配置项缺失时系统仍可正常运行。Info.parse() 是配置加载链的最后一道关卡——任何不符合 Schema 的配置都会在此时被拦截并给出清晰的错误提示。
核心流程
六层配置加载优先级
Config 的加载不是简单的”文件覆盖文件”,而是一套六层发现-合并流程(优先级从低到高):
第 1 层:内置默认值
│ Schema 中 .default() 定义的值
▼
第 2 层:全局配置文件
│ ~/.config/opencode/config.json
│ ~/.config/opencode/opencode.json
│ ~/.config/opencode/opencode.jsonc
▼
第 3 层:OPENCODE_CONFIG 环境变量
│ 环境变量指定的配置文件路径
│ 适合 CI/CD 场景或临时覆盖
▼
第 4 层:项目配置(findUp)
│ 从当前目录向上查找 opencode.jsonc / opencode.json
│ 可提交 Git,团队成员共享
▼
第 5 层:.opencode/ 目录
│ 项目内 .opencode/config.json
│ 不提交 Git(应在 .gitignore 中),个人偏好
▼
第 6 层:OPENCODE_CONFIG_CONTENT 环境变量
│ 环境变量直接传入 JSON 内容(优先级最高)
│ 适合容器化部署,避免挂载配置文件
注意:CLI flag 不在合并链中。命令行参数在调用侧直接覆盖,而非参与
mergeDeep。例如--model anthropic/claude-sonnet-4会在SessionPrompt层直接覆盖Config的模型选择。
加载与解析:JSONC + 变量替换
load() 函数处理单个配置文件的完整加载流程:
load(filePath)
├─ 读取文件内容(Bun.file().text())
├─ JSONC 解析
│ └─ jsonc-parser 的 parseJsonC()
│ 支持单行注释 //、多行注释 /* */、尾逗号
├─ 变量替换(递归遍历所有字符串值)
│ ├─ {env:VAR} → process.env.VAR
│ │ 例:{env:OPENAI_API_KEY} → sk-xxxxx
│ ├─ {file:path} → 读取外部文件内容
│ │ 例:{file:./prompts/system.txt} → 文件内容字符串
│ └─ 未匹配的环境变量 → 保留原字符串并输出警告
└─ 返回解析后的原始对象(尚未经过 Zod 验证)
{env:VAR} 替换让 API 密钥等敏感信息可以留在环境变量中,而不是明文写入配置文件。{file:path} 支持引入外部文件(如长 prompt 模板),保持配置文件简洁。变量替换在 Zod 验证之前执行,这样 Schema 可以对替换后的实际值进行类型校验。
合并策略:mergeDeep + 数组拼接
多层配置合并使用 remeda 的 mergeDeep,但针对特定字段做了数组拼接的增强:
function mergeConfigConcatArrays(base: Info, ...overrides: Info[]): Info {
// 基于 remeda.mergeDeep 的深度合并
// 特殊行为:
// - plugins、instructions、command 等数组字段 → 拼接而非覆盖
// - provider、mcp、agent 等字典字段 → 深度合并
// - model、temperature 等简单值 → 后者覆盖前者
return overrides.reduce((acc, override) => {
return customMerge(acc, override)
}, base)
}
合并行为总结:
| 字段类型 | 合并策略 | 示例 |
|---|---|---|
简单值(model, temperature) | 后者覆盖前者 | 项目 model 覆盖全局 model |
字典(provider, mcp, agent) | 深度合并,按 key 合并 | 全局 provider.openai + 项目 provider.openai → 合并 |
数组(plugins, instructions, command) | 拼接,不覆盖 | 全局 plugins + 项目 plugins = 合并列表 |
这保证了项目级 plugins 不会覆盖用户级 plugins,而是合并,两种配置可以互补。
插件去重:deduplicatePlugins
合并后可能存在重复插件(全局配置和项目配置都声明了同一个插件)。deduplicatePlugins() 按 canonical name 去重:
function deduplicatePlugins(plugins: Plugin[]): Plugin[] {
// 按 canonical name 分组
// 每组保留优先级最高的那个(即后加载的配置源中的插件)
// canonical name:去除版本号、去除 scope 的包名
// 例:@scope/my-plugin@1.0.0 和 @scope/my-plugin@2.0.0 视为同一个
const seen = new Map<string, Plugin>()
for (const plugin of plugins) {
const canonical = toCanonical(plugin.name)
seen.set(canonical, plugin) // 后写入的覆盖先写入的
}
return [...seen.values()]
}
去重规则:高优先级配置源中的插件胜出。因为 plugins 数组是按优先级从低到高拼接的,所以后出现的同名插件自然覆盖先出现的。
Config.Service — Effect 服务架构
Config 模块通过 Effect Service 暴露接口:
export class Service extends Effect.Service<Service>("Config.Service")(
undefined, // Service 上下文定义
() =>
Effect.gen(function* () {
// 初始化配置状态
const cfg = yield* loadConfig()
return {
get: () => cfg,
update: (newConfig) => updateConfig(newConfig),
// ...
}
}),
) {}
上层模块通过 Effect.provide(Service.Default) 注入配置服务,Config.get() 获取当前配置。Effect 的依赖注入让配置在测试中可以被轻松替换。
InstanceState 缓存
Config 的加载结果通过 Instance.state() 缓存,与项目实例的生命周期绑定:
const state = Instance.state(async () => {
// 六层配置加载 + 合并 + 验证
return await loadAllLayers()
})
export async function get() {
return state() // 首次调用时执行加载,后续返回缓存
}
Instance.state() 返回一个异步函数,首次调用时执行工厂函数并缓存结果。缓存在 Instance.dispose() 时自动清理——这意味着配置更新时的”销毁-重建”会触发重新加载。
配置更新与全量热重载
update() 函数实现配置的热更新,采用”销毁-重建”策略:
update(newConfig)
├─ JSON.stringify(newConfig, null, 2)
├─ fs.writeFile(projectConfigPath, content)
│ └─ 写入项目级 .opencode/config.json
├─ Instance.dispose(currentState)
│ └─ 销毁当前实例的所有缓存
│ ├─ Config state 缓存清理
│ ├─ Agent state 缓存清理
│ ├─ Provider state 缓存清理
│ └─ MCP 连接关闭
└─ Instance.state()
└─ 重新加载,触发完整初始化链:
├─ 加载所有配置层(六层)
├─ mergeDeep 合并
├─ deduplicatePlugins 去重
├─ Zod Schema 验证
├─ 安装依赖(plugins)
├─ 加载命令/agent/mode/plugin(通过 glob 模式扫描)
└─ 构建新的 InstanceState
设计权衡:没有实现增量更新,而是”销毁-重建”。简单可靠,但重载期间有短暂的不可用窗口。在实际使用中,这个窗口非常短(通常少于 100ms),因为配置加载本身只是文件读取和 Schema 验证。
TUI 配置与迁移
TUI(Terminal UI)配置有独立的 Schema 和迁移逻辑,支持从旧版本自动升级:
// tui-migrate.ts — 旧版配置自动迁移
function migrate(oldConfig: unknown): TUIConfig {
// 检测旧版格式并转换为新格式
// 例:旧版的 keybind 字段名映射到新版的 keybinds
// 迁移后自动保存,用户无感知
}
TUI 配置包括快捷键绑定、主题颜色、面板布局等 UI 细节。独立出来是因为 TUI 配置变更频繁,且与核心业务配置(模型、权限)的生命周期不同。
调用链示例
链路 1:项目启动时加载配置
用户在项目目录运行 opencode
│
▼
Instance.state() // 首次调用,触发配置加载
│
├─ loadBuiltinDefaults()
│ └─ 返回 Schema 中 .default() 定义的内置默认值
│
├─ loadGlobalConfig()
│ └─ ConfigPaths.global() → ~/.config/opencode/
│ └─ 尝试读取 config.json / opencode.json / opencode.jsonc(按优先级)
│ └─ load(globalConfigPath) → JSONC 解析 + 变量替换
│
├─ loadEnvConfig()
│ └─ process.env.OPENCODE_CONFIG → 指定路径的配置文件
│ └─ 若存在 → load(envConfigPath)
│
├─ loadProjectConfig()
│ └─ findUp("opencode.jsonc", "opencode.json")
│ └─ 从 cwd 向上查找,找到第一个即停止
│ └─ load(projectConfigPath)
│
├─ loadOpencodeDir()
│ └─ .opencode/config.json
│ └─ load(opencodeDirPath)
│
├─ loadEnvContent()
│ └─ process.env.OPENCODE_CONFIG_CONTENT → 直接解析 JSON
│
├─ mergeConfigConcatArrays(defaults, global, env, project, opencodeDir, envContent)
│ └─ 六层按优先级从低到高合并
│ └─ 数组字段拼接、字典字段深度合并、简单值覆盖
│
├─ deduplicatePlugins(merged.plugins)
│ └─ 按 canonical name 去重
│
├─ Info.parse(result)
│ └─ Zod Schema 验证,类型安全的配置对象
│
└─ 存入 InstanceState 缓存
链路 2:环境变量替换 API 密钥
配置文件 opencode.jsonc:
{
"provider": {
"openai": {
"apiKey": "{env:OPENAI_API_KEY}", // 占位符
"baseURL": "{env:CUSTOM_OPENAI_URL}"
}
}
}
│
▼
load(filePath)
├─ 读取文件内容
├─ JSONC.parse(content)
│ └─ 支持 // 和 /* */ 注释
│ └─ 结果: { provider: { openai: { apiKey: "{env:OPENAI_API_KEY}", ... } } }
├─ 变量替换(递归遍历所有字符串值)
│ ├─ "{env:OPENAI_API_KEY}" → process.env.OPENAI_API_KEY → "sk-xxxxx"
│ ├─ "{env:CUSTOM_OPENAI_URL}" → process.env.CUSTOM_OPENAI_URL → "https://..."
│ └─ 结果: { provider: { openai: { apiKey: "sk-xxxxx", baseURL: "https://..." } } }
├─ Provider Schema 验证
└─ 运行时 apiKey 为实际值,配置文件中无明文密钥
链路 3:配置变更传播(全量重载)
用户在 TUI 修改模型选择(从 claude-sonnet-4 → gpt-5)
│
▼
Config.update({ model: "openai/gpt-5" })
│
├─ 读取当前项目配置
├─ 合并新值到配置对象
├─ fs.writeFile(projectConfigPath, JSON.stringify(newConfig, null, 2))
│
├─ Instance.dispose(currentState)
│ ├─ 清理 Config state 缓存
│ ├─ 清理 Provider state 缓存(关闭旧的 SDK 实例)
│ ├─ 清理 Agent state 缓存
│ ├─ 关闭 MCP 服务器连接
│ └─ 广播 Instance disposed 事件
│
└─ Instance.state() → 触发完整的重新初始化
├─ 重新加载六层配置
├─ Provider 初始化:新的模型 gpt-5 注册到 Provider.Info
├─ Agent 初始化:读取更新后的配置
├─ MCP 重连:按新配置启动 MCP 服务器
└─ 所有依赖 Config 的模块获得新配置
链路 4:全局 + 项目配置合并
全局配置 ~/.config/opencode/config.json:
{
"provider": {
"anthropic": { "apiKey": "{env:ANTHROPIC_API_KEY}" },
"openai": { "apiKey": "{env:OPENAI_API_KEY}" }
},
"plugins": ["plugin-a", "plugin-b"],
"permission": {
"bash": "ask" // 全局默认:bash 需要 ask
}
}
项目配置 ./opencode.jsonc:
{
"provider": {
"anthropic": {
"disabled": true // 项目禁用 Anthropic
}
},
"plugins": ["plugin-c"],
"permission": {
"bash": "allow", // 项目覆盖:bash 自动放行
"edit": "allow" // 项目覆盖:edit 自动放行
}
}
│
▼
合并结果:
{
provider: {
anthropic: { apiKey: "sk-xxx", disabled: true }, // 深度合并
openai: { apiKey: "sk-yyy" }
},
plugins: ["plugin-a", "plugin-b", "plugin-c"], // 数组拼接
permission: {
bash: "allow", // 项目覆盖全局
edit: "allow" // 项目新增
}
}
链路 5:外部文件引入(file:path 占位符替换)
配置文件 opencode.jsonc:
{
"agent": {
"custom-reviewer": {
"prompt": "{file:./prompts/review-prompt.md}", // 引入外部文件
}
}
}
文件 ./prompts/review-prompt.md 内容:
你是一个专业的代码审查员。请检查以下方面的内容:
1. 安全漏洞
2. 性能问题
3. 代码风格一致性
...
│
▼
变量替换后:
{
agent: {
"custom-reviewer": {
prompt: "你是一个专业的代码审查员。请检查以下方面的内容:\n1. 安全漏洞\n2. 性能问题\n3. 代码风格一致性\n..."
}
}
}
设计取舍
| 决策 | 理由 |
|---|---|
| JSONC 而非 YAML | JSONC 在保持 JSON 兼容性的同时支持注释,工具链生态更好(IDE 语法高亮、JSONC parser 成熟)。YAML 的缩进敏感和隐式类型转换容易引入 bug |
| 全量重载 vs 增量更新 | 选择全量重载,牺牲了重载性能,但避免了复杂的状态同步问题。增量更新需要跟踪”哪些模块依赖哪些配置项”,在模块间耦合松散的架构中维护成本极高 |
| Zod Schema 驱动 | 相比手动类型定义,Zod 同时提供运行时验证和编译时类型推导,一套 Schema 双重保障。z.infer<typeof Schema> 自动从 Schema 推导 TypeScript 类型 |
| 数组拼接而非覆盖 | plugins/instructions/command 等字段的拼接行为让全局和项目配置可以互补,而非互斥。如果用覆盖,用户需要在项目配置中重复声明全局已有内容 |
| six 层配置而非无限继承 | 六层优先级覆盖了常见的使用场景(默认值、全局、CI、团队共享、个人偏好、容器),更多层次会增加理解成本和调试难度 |
| findUp 向上查找 | 从 cwd 向上查找配置文件,支持 monorepo 中子目录运行 opencode 时自动找到根目录的配置 |
| catchall 默认 ask | 新增工具类型不会因为缺少显式配置而意外放行,安全优先 |
| InstanceState 缓存 | 配置加载涉及多层文件读取和 Schema 验证,缓存避免重复开销。缓存生命周期与项目实例绑定,切换项目时自动失效 |
与其他模块的关系
- Provider:Provider 初始化时从 Config 读取模型配置和 API 密钥(经
{env:VAR}变量替换后的实际值)。Config.get().provider提供 Provider 注册和认证信息 - Permission:权限规则通过
PermissionSchema 定义在 Config 中,Permission.fromConfig()将配置解析为运行时 Ruleset。Config 的catchall默认值确保新工具类型安全 - Agent:Agent 的行为参数(最大循环次数、默认 model、温度、权限覆盖)从 Config 的
agent字段读取。Agent 列表通过Instance.state()缓存 - MCP:MCP 服务器列表(
mcp字段)和配置来自 Config,支持 local(stdio)和 remote(SSE)两种模式。MCP 配置变更触发重连 - Command:自定义命令通过 Config 的
command数组定义,与内置命令合并后注册到 Command 模块 - CLI / TUI:TUI 的快捷键绑定(
keybinds)、主题(tui)等 UI 配置来自 Config。TUI 配置有独立的迁移逻辑 - Session:InstanceState 与 Session 共享生命周期,配置变更触发 Session 级别的重载。Session 在初始化时读取 Config 确定模型和 Agent 配置
- Plugin:Plugin 列表从 Config 加载,
deduplicatePlugins()确保同一插件只加载一次。Plugin 的启用/禁用通过配置控制 - Instance:Config 的缓存通过
Instance.state()管理,Instance.dispose()时自动清理。配置更新触发完整的实例重建