Config 소스 코드 분석
모듈 개요
Config 모듈은 OpenCode의 모든 설정을 관리합니다 — 모델 선택부터 권한 규칙, MCP 서버부터 키바인딩까지. Zod Schema 기반 타입 시스템과 6계층 설정 우선순위 병합을 결합하여, 프로젝트 레벨의 공유 설정과 사용자 개인 설정이 질서 있게 공존할 수 있습니다.
핵심 설계 선택:
- 전체 Zod Schema 커버리지: 모든 타입 정의에 Zod를 사용하여 런타임 검증과 컴파일 타임 타입 안전성을 제공
- 6계층 설정 우선순위: 내장 기본값 → 전역 설정 → 환경 변수 파일 → 프로젝트 설정 → .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 정의, 6계층 로딩 로직, 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가 실행할 수 있는 모든 작업 카테고리를 정의합니다:
// Permission action tri-state enum
const PermissionAction = z.enum(["ask", "allow", "deny"]);
// Permission rules: independent control over 14+ operations
const Permission = z.object({
read: PermissionAction.default("allow"), // 파일 읽기
edit: PermissionAction.default("ask"), // 파일 편집
bash: PermissionAction.default("ask"), // 셸 명령 실행
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"));
설계 의도: 기본 정책은 “읽기 작업은 허용, 쓰기 작업은 확인”으로 효율성과 안전성의 균형을 맞춥니다. catchall은 새로 추가된 도구가 실수로 허용되는 것을 방지합니다. 목록에 없는 작업 타입은 기본적으로 ask 흐름을 따릅니다.
Provider Schema — 모델 Provider 설정
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 필드를 기반으로 나머지 필드를 자동으로 좁힐(narrowing) 수 있습니다.
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에 맞지 않는 설정은 명확한 에러 메시지와 함께 여기서 차단됩니다.
코어 플로우
6계층 설정 로딩 우선순위
설정 로딩은 단순한 “파일 덮어쓰기”가 아닌 6계층의 발견-병합 프로세스입니다 (우선순위 낮음→높음):
Layer 1: 내장 기본값
│ Schema의 .default()로 정의된 값
▼
Layer 2: 전역 설정 파일
│ ~/.config/opencode/config.json
│ ~/.config/opencode/opencode.json
│ ~/.config/opencode/opencode.jsonc
▼
Layer 3: OPENCODE_CONFIG 환경 변수
│ 환경 변수로 지정된 설정 파일 경로
│ CI/CD 시나리오나 임시 오버라이드에 적합
▼
Layer 4: 프로젝트 설정 (findUp)
│ 현재 디렉토리에서 상향으로 opencode.jsonc / opencode.json 검색
│ Git에 커밋 가능, 팀원 간 공유
▼
Layer 5: .opencode/ 디렉토리
│ 프로젝트 내 .opencode/config.json
│ Git에 커밋하지 않음 (.gitignore에 포함 권장), 개인 설정
▼
Layer 6: OPENCODE_CONFIG_CONTENT 환경 변수
│ 환경 변수로 JSON 내용을 직접 전달 (최고 우선순위)
│ 컨테이너 배포에 적합, 설정 파일 마운트 불필요
참고: CLI 플래그는 병합 체인에 포함되지 않습니다. 명령줄 인수는
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}는 외부 파일(긴 프롬프트 템플릿 등)의 가져오기를 지원하여 설정 파일을 간결하게 유지합니다. 변수 치환은 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) | 깊은 병합, 키 단위로 병합 | 전역 provider.openai + 프로젝트 provider.openai → 병합됨 |
배열 (plugins, instructions, command) | 연결, 덮어쓰지 않음 | 전역 plugins + 프로젝트 plugins = 통합 목록 |
이를 통해 프로젝트 레벨의 plugins가 사용자 레벨의 plugins를 덮어쓰는 대신 병합됩니다. 두 설정이 상호 보완할 수 있습니다.
플러그인 중복 제거: deduplicatePlugins
병합 후 중복 플러그인이 존재할 수 있습니다 (전역과 프로젝트 설정 모두 같은 플러그인을 선언). deduplicatePlugins()는 정규화된 이름으로 중복을 제거합니다:
function deduplicatePlugins(plugins: Plugin[]): Plugin[] {
// 정규 이름으로 그룹화
// 각 그룹에서 가장 높은 우선순위의 것을 유지 (즉, 나중에 로딩된 설정 소스의 플러그인)
// 정규 이름: 버전과 스코프가 제거된 패키지 이름
// 예: @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()]
}
Config.Service — Effect Service 아키텍처
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()을 통해 현재 설정에 접근합니다.
설정 업데이트와 완전한 핫 리로드
update() 함수는 “파기-재구축” 전략을 사용하여 핫 설정 업데이트를 구현합니다:
update(newConfig)
├─ JSON.stringify(newConfig, null, 2)
├─ fs.writeFile(projectConfigPath, content)
│ └─ 프로젝트 레벨 .opencode/config.json에 쓰기
├─ Instance.dispose(currentState)
│ └─ 현재 인스턴스의 모든 캐시 파기
│ ├─ Config 상태 캐시 정리
│ ├─ Agent 상태 캐시 정리
│ ├─ Provider 상태 캐시 정리
│ └─ MCP 연결 종료
└─ Instance.state()
└─ 재로딩, 전체 초기화 체인 트리거:
├─ 모든 설정 계층 로딩 (6계층)
├─ mergeDeep 병합
├─ deduplicatePlugins 중복 제거
├─ Zod Schema 검증
├─ 의존성 설치 (플러그인)
├─ 명령/에이전트/모드/플러그인 로딩
└─ 새로운 InstanceState 구축
호출 체인 예제
체인 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 → 지정된 경로의 설정 파일
│
├─ loadProjectConfig()
│ └─ findUp("opencode.jsonc", "opencode.json")
│ └─ cwd에서 상향 검색, 첫 번째 일치에서 중지
│ └─ load(projectConfigPath)
│
├─ loadOpencodeDir()
│ └─ .opencode/config.json
│
├─ loadEnvContent()
│ └─ process.env.OPENCODE_CONFIG_CONTENT → JSON 직접 파싱
│
├─ mergeConfigConcatArrays(defaults, global, env, project, opencodeDir, envContent)
│ └─ 6계층을 낮은 우선순위에서 높은 순으로 병합
│
├─ deduplicatePlugins(merged.plugins)
│
├─ Info.parse(result)
│ └─ Zod Schema 검증, 타입 안전한 설정 객체
│
└─ InstanceState 캐시에 저장
체인 2: 설정 변경 전파 (완전 리로드)
사용자가 TUI에서 모델 선택 변경 (claude-sonnet-4 → gpt-5)
│
▼
Config.update({ model: "openai/gpt-5" })
│
├─ 현재 프로젝트 설정 읽기
├─ 새 값을 설정 객체에 병합
├─ fs.writeFile(projectConfigPath, JSON.stringify(newConfig, null, 2))
│
├─ Instance.dispose(currentState)
│ ├─ Config 상태 캐시 정리
│ ├─ Provider 상태 캐시 정리 (이전 SDK 인스턴스 종료)
│ ├─ Agent 상태 캐시 정리
│ ├─ MCP 서버 연결 종료
│ └─ Instance 폐기 이벤트 브로드캐스트
│
└─ Instance.state() → 전체 재초기화 트리거
├─ 6계층 설정 재로딩
├─ Provider 초기화: 새 모델 gpt-5가 Provider.Info에 등록
├─ Agent 초기화: 업데이트된 설정 읽기
├─ MCP 재연결: 새 설정에 따라 MCP 서버 시작
└─ Config에 의존하는 모든 모듈이 새 설정을 수신
설계 트레이드오프
| 결정 | 근거 |
|---|---|
| YAML이 아닌 JSONC | JSONC는 JSON 호환성을 유지하면서 주석을 지원하며, 더 나은 도구 생태계(IDE 구문 강조, 성숙한 JSONC 파서)를 가집니다. YAML의 들여쓰기 민감성과 암시적 타입 변환은 쉽게 버그를 유발합니다 |
| 증분 업데이트 대신 완전 리로드 | 복잡한 상태 동기화 문제를 회피하기 위해 리로드 성능을 희생했습니다. 증분 업데이트는 “어떤 모듈이 어떤 설정 항목에 의존하는지”를 추적해야 하지만, 느슨한 결합 아키텍처에서는 유지 비용이 매우 높습니다 |
| Zod Schema 기반 | 수동 타입 정의와 비교하여 Zod는 런타임 검증과 컴파일 타임 타입 추론을 모두 제공합니다. 하나의 Schema로 이중 보장 |
| 덮어쓰기가 아닌 배열 연결 | plugins/instructions/command 필드의 연결 동작으로 인해 전역과 프로젝트 설정이 상호 배타적이 아닌 보완적으로 작동합니다 |
| 무제한 상속이 아닌 6계층 | 6계층 우선순위는 일반적인 사용 사례(기본값, 전역, CI, 팀 공유, 개인 설정, 컨테이너)를 커버합니다 |
| catchall의 기본값은 ask | 새로운 도구 타입이 명시적 설정 누락으로 인해 실수로 허용되지 않습니다. 안전 우선 |
| InstanceState 캐시 | 설정 로딩은 다계층 파일 읽기와 Schema 검증을 포함합니다. 캐시는 반복 비용을 피합니다. 캐시 수명 주기는 프로젝트 인스턴스에 바인딩되어 프로젝트 전환 시 자동으로 무효화됩니다 |
다른 모듈과의 관계
- Provider: 초기화 시 Provider는 Config에서 모델 설정과 API 키를 읽습니다(
{env:VAR}변수 치환 후의 실제 값).Config.get().provider가 Provider 등록과 인증 정보를 제공합니다 - Permission: 권한 규칙은
PermissionSchema를 통해 Config에서 정의됩니다.Permission.fromConfig()가 설정을 런타임 Ruleset으로 파싱합니다 - Agent: Agent 동작 매개변수(최대 루프 수, 기본 모델, temperature, 권한 오버라이드 등)는 Config의
agent필드에서 읽힙니다 - MCP: MCP 서버 목록(
mcp필드)과 그 설정은 Config에서 옵니다. local(stdio)과 remote(SSE) 두 모드를 모두 지원합니다 - Command: 커스텀 명령은 Config의
command배열을 통해 정의됩니다. 내장 명령과 병합되어 Command 모듈에 등록됩니다 - CLI / TUI: TUI 키보드 단축키(
keybinds), 테마(tui), 기타 UI 설정은 Config에서 옵니다 - Session: InstanceState는 Session과 수명 주기를 공유합니다. 설정 변경은 Session 레벨의 리로드를 트리거합니다
- Plugin: 플러그인 목록은 Config에서 로딩됩니다.
deduplicatePlugins()가 각 플러그인이 한 번만 로딩되도록 보장합니다 - Instance: Config 캐시는
Instance.state()를 통해 관리되며,Instance.dispose()시 자동으로 정리됩니다. 설정 업데이트는 전체 인스턴스 재구축을 트리거합니다