CLI 소스 코드 분석
모듈 개요
CLI는 OpenCode의 명령줄 진입 레이어로, packages/opencode/src/cli/에 위치합니다. TypeScript 버전은 Go 버전의 Cobra 대신 Yargs를 사용하여 명령줄 인수 및 서브커맨드를 파싱하며, 두 가지 실행 모드를 지원합니다:
- TUI 모드 (기본값): Ink(React 기반 터미널 UI 렌더링 엔진)를 시작하며, 백그라운드 Worker 스레드가 SSE 이벤트 스트림을 통해 Server와 통신하여 Agent 상호작용과 도구 실행을 처리합니다
- 비대화형 모드 (
run서브커맨드): 단일 프롬프트를 실행하고 종료합니다.@opencode-ai/sdkv2의OpencodeClient를 기반으로 20개 이상의 도구 유형에 대한 인라인 렌더링을 포함한 완전한 Agent 세션을 구현합니다
Go 버전과의 근본적인 차이는: Go 버전은 인프로세스 App 서비스 인스턴스를 직접 보유하지만, TS 버전은 독립적인 Server 프로세스와 SSE를 통해 통신합니다 — CLI는 순수 클라이언트이며, Server가 모든 비즈니스 로직을 담당합니다. 이 아키텍처를 통해 CLI는 opencode serve로 시작된 서버에 원격으로 연결할 수 있어 “로컬 편집, 원격 추론” 워크플로우가 가능합니다.
핵심 책임:
- 명령줄 인수 파싱 및 서브커맨드 라우팅 (Yargs 프레임워크)
- 프로젝트 인스턴스 초기화 (
bootstrap->Instance.provide) - SSE 이벤트 스트림 구축 및 양방향 통신
- 비대화형 모드에서의 도구 상태 렌더링 (20개 이상의 도구 유형)
- 서브커맨드 시스템 관리 (session, agent, mcp, models, providers, serve 등)
주요 파일
| 파일 경로 | 줄 수 | 책임 |
|---|---|---|
src/cli/bootstrap.ts | ~17 | 프로젝트 초기화 래퍼: Instance.provide를 호출하여 프로젝트 디렉토리와 설정 구성 |
src/cli/cmd/cmd.ts | ~6 | cmd() 헬퍼 함수: Yargs CommandModule의 타입 안전 래퍼 |
src/cli/cmd/run.ts | ~690 | run 서브커맨드: 비대화형 모드 핵심 구현, SSE 이벤트 스트림 소비, 도구 렌더링, 파일 첨부, 권한 처리 |
src/cli/cmd/tui/worker.ts | ~175 | TUI Worker: SSE 연결 관리, Agent 세션 프록시, Ink UI로의 이벤트 전달 |
src/cli/cmd/tui/event.ts | — | TUI BusEvent 정의: Worker와 Ink UI 간의 이벤트 프로토콜 |
src/cli/cmd/session.ts | — | session 서브커맨드: 세션 CRUD, 공유, 아카이브 작업 |
src/cli/cmd/agent.ts | — | agent 서브커맨드: Agent 목록 조회 및 관리 |
src/cli/cmd/mcp.ts | — | mcp 서브커맨드: MCP 서버 관리 |
src/cli/cmd/models.ts | — | models 서브커맨드: 사용 가능한 모델 목록 |
src/cli/cmd/providers.ts | — | providers 서브커맨드: 사용 가능한 Provider 목록 |
src/cli/cmd/serve.ts | — | serve 서브커맨드: 로컬 HTTP 서버 시작 |
src/cli/cmd/debug/ | — | debug 서브커맨드 그룹: 디버깅 도구 모음 |
src/cli/ui.ts | — | 터미널 UI 헬퍼: Style 상수, inline()/block() 포맷팅 함수 |
src/cli/logo.ts | — | 로고 ASCII 아트: 시작 시 브랜드 아이덴티티 렌더링 |
src/cli/heap.ts | — | 힙 스냅샷: 메모리 분석을 위한 writeHeapSnapshot() |
src/cli/upgrade.ts | — | 자동 업그레이드: 새 버전 감지 및 설치 |
src/cli/network.ts | — | 네트워크 감지: 연결 확인 및 타임아웃 처리 |
src/cli/error.ts | — | 에러 처리: 전역 예외 캡처 및 포맷팅된 출력 |
타입 시스템
UI.Style — 터미널 스타일 상수
ui.ts는 통일된 터미널 출력 스타일 상수를 정의하여, 모든 서브커맨드에서 일관된 출력 포맷팅을 보장합니다:
export const Style = {
TEXT_NORMAL, // 기본 텍스트
TEXT_DIM, // 회색 흐림 텍스트 (보조 정보)
TEXT_INFO_BOLD, // 파란색 굵게 (제목, 상태)
TEXT_WARNING_BOLD, // 노란색 굵게 (경고)
TEXT_DANGER_BOLD, // 빨간색 굵게 (에러, 거부)
} as const
UI 출력 함수
// 단일 라인 정보 표시: 아이콘 + 제목 + 설명
export function inline(info: {
icon: string
title: string
description?: string
}): void
// 구분선이 있는 정보 블록: 제목 + 선택적 출력 내용
export function block(info: {
icon: string
title: string
description?: string
}, output?: string): void
inline()은 간결한 상태 힌트에 사용되며(예: “Agent: build”), block()은 상세한 출력 내용을 표시해야 하는 시나리오에 사용됩니다(예: 도구 실행 결과).
cmd() 헬퍼 함수
// cmd.ts — 타입 안전한 Yargs CommandModule 래퍼
export function cmd(mod: CommandModule): CommandModule {
return mod
}
단 6줄의 코드에 불과하지만, cmd()의 가치는 모든 서브커맨드에 대해 통일된 타입 서명 제약을 제공하는 데 있습니다 — 각 서브커맨드 모듈은 cmd()를 통해 내보내지며, Yargs의 CommandModule 인터페이스가 올바르게 구현됨을 보장합니다.
코어 플로우
진입점과 명령 라우팅
CLI 진입점은 packages/opencode/src/node.ts에 있으며, Yargs를 사용하여 완전한 서브커맨드 시스템을 정의합니다:
opencode # 메인 진입점 (인수 없음 -> TUI 모드 실행)
|-- run [message] # 비대화형 모드
| |-- --continue, -c # 이전 세션 계속
| |-- --session, -s # 세션 ID 지정
| |-- --fork # 기존 세션에서 포크
| |-- --model, -m # 모델 지정 (provider/model 형식)
| |-- --agent # 실행 Agent 지정
| |-- --format # 출력 형식 (default / json)
| |-- --file, -f # 컨텍스트에 파일 첨부
| |-- --attach # 원격 서버에 연결
| |-- --thinking # 추론 과정 표시
| `-- --dangerously-skip-permissions # 권한 확인 건너뛰기
|-- session # 세션 관리
| |-- list # 모든 세션 나열
| |-- create # 새 세션 생성
| |-- delete # 세션 삭제
| |-- share # 세션 공유
| `-- archive # 세션 아카이브
|-- agent # Agent 관리 (내장 및 커스텀 Agent 나열)
|-- mcp # MCP 서버 관리
|-- models # 모델 목록 (Provider별 그룹화)
|-- providers # Provider 목록
|-- serve # 로컬 HTTP 서버 시작
|-- config # 설정 관리
`-- debug # 디버그 도구 그룹
각 서브커맨드는 cmd()로 래핑되어 Yargs에 등록됩니다. 예를 들어, session 서브커맨드는 내부적으로 중첩된 Yargs 빌더를 사용하여 list, create, delete, share, archive라는 5개의 2단계 서브커맨드를 정의합니다.
프로젝트 초기화: bootstrap()
bootstrap.ts는 프로젝트 컨텍스트가 필요한 모든 서브커맨드의 공통 전제 조건입니다:
// 간소화된 bootstrap 구현
export async function bootstrap(
dir: string, // 프로젝트 작업 디렉토리
cb: () => Promise<void> // 초기화 완료 후 콜백
) {
await Instance.provide({ directory: dir })
await cb()
}
Instance.provide는 주어진 디렉토리를 기반으로 완전한 프로젝트 인스턴스를 초기화합니다 — 설정 로드, Storage 연결, Provider 등록, MCP 서버 발견 등을 수행합니다. bootstrap()은 이 초기화 과정을 통일된 진입점으로 래핑하므로, 서브커맨드는 자체 비즈니스 로직에만 집중하면 됩니다.
비대화형 모드: run 명령어 상세 분석
run.ts (~690줄)는 CLI 모듈에서 가장 복잡한 파일로, 완전한 비대화형 Agent 세션을 구현합니다. 코어 플로우는 4단계로 나뉩니다:
1단계: 초기화 및 세션 준비
// 간소화된 초기화 시퀀스
await bootstrap(dir, async () => {
// 1. 명령줄 인수 파싱
const message = argv._[0] as string // 사용자 메시지
const sessionID = argv.session // 지정된 세션 ID
const continue_ = argv.continue // 이전 세션 계속
const fork = argv.fork // 세션 포크
const model = argv.model // 모델 선택
const agent = argv.agent // Agent 선택
const files = argv.file as string[] ?? [] // 첨부 파일
const format = argv.format ?? "default" // 출력 형식
// 2. SSE 클라이언트 연결 생성
const client = new OpencodeClient(/* ... */)
})
2단계: 파일 첨부 및 세션 포크
run 명령은 --file 매개변수를 통해 파일 내용을 사용자 메시지에 주입할 수 있습니다:
// 파일 첨부 로직: 파일 내용을 메시지 컨텍스트로 포함
const fileParts: Part[] = []
for (const filePath of files) {
const content = await readFile(filePath, "utf-8")
fileParts.push({
type: "text",
text: `--- ${filePath} ---\n${content}`,
})
}
// 파일 내용이 사용자 메시지 앞에 추가됨
세션 포크 메커니즘은 사용자가 기존 세션에서 새 브랜치를 생성할 수 있게 합니다:
--fork <sessionID>: 지정된 세션의 메시지 히스토리를 기반으로 새 세션 생성--fork+--continue: 포크 후 새 세션에서 대화 계속--continue(--fork없이): 가장 최근 세션을 직접 계속
3단계: SSE 이벤트 스트림 소비 및 도구 렌더링
이것이 run 명령의 핵심입니다 — SSE 연결을 통해 Agent의 스트리밍 응답을 실시간으로 수신하고, 이벤트 유형에 따라 해당 렌더링 함수를 호출합니다:
// 메인 이벤트 스트림 소비 루프 (간소화)
for await (const event of client.events()) {
switch (event.type) {
case "message":
// 텍스트 출력 -> 터미널에 직접 출력
process.stdout.write(event.content)
break
case "tool":
// 도구 이벤트 -> 해당 렌더링 함수 호출
renderTool(event.tool, event.state)
break
case "error":
// 에러 -> 포맷팅된 출력
UI.inline({
icon: "✗",
title: event.name,
description: event.message,
})
break
case "permission":
// 권한 요청 -> 비대화형 모드에서 자동 거부
if (!dangerouslySkipPermissions) {
UI.inline({
icon: "🔒",
title: "Permission denied",
description: event.tool,
})
}
break
}
}
4단계: 출력 포맷팅
run 명령은 두 가지 출력 형식을 지원합니다:
--format default: 사람이 읽을 수 있는 터미널 출력,UI.inline()과UI.block()을 사용하여 도구 상태 포맷팅--format json: 구조화된 JSON 이벤트 스트림, 한 줄에 하나의 JSON 객체, 스크립트 통합 및 CI/CD 시나리오에 적합
도구 렌더링 시스템
run.ts는 20개 이상의 도구 유형에 대한 전용 렌더링 함수를 포함하고 있으며, 각각의 의미에 따라 다른 정보를 표시합니다:
// glob 도구: 검색 패턴, 일치 수, 루트 디렉토리 표시
function renderGlob(tool: ToolEvent) {
UI.inline({
icon: "📁",
title: `glob: ${tool.input.pattern}`,
description: `${tool.matchCount} matches in ${tool.input.root}`,
})
}
// grep 도구: 검색 패턴, 일치 수 표시
function renderGrep(tool: ToolEvent) {
UI.inline({
icon: "🔍",
title: `grep: ${tool.input.pattern}`,
description: `${tool.matchCount} matches`,
})
}
// read 도구: 파일 경로 및 매개변수 표시
function renderRead(tool: ToolEvent) {
UI.inline({
icon: "📄",
title: `read: ${tool.input.filePath}`,
description: tool.input.offset ? `lines ${tool.input.offset}-${tool.input.offset + tool.input.limit}` : "",
})
}
// edit/write 도구: diff 표시
function renderEdit(tool: ToolEvent) {
UI.block({
icon: "✏️",
title: `edit: ${tool.input.filePath}`,
}, tool.diff) // 구체적인 diff 내용 표시
}
// bash 도구: 명령 + 출력
function renderBash(tool: ToolEvent) {
UI.block({
icon: "⚡",
title: tool.input.command,
}, tool.output)
}
// task 도구: 하위 Agent 작업 상태
function renderTask(tool: ToolEvent) {
UI.inline({
icon: "🤖",
title: `task: ${tool.input.description}`,
description: tool.state, // pending / running / completed / error
})
}
추가로 webfetch, codesearch, websearch, skill, todo 등의 도구에 대한 렌더링 함수도 있습니다. 인식할 수 없는 도구 유형의 경우 폴백 렌더러가 사용되어 — 도구 이름과 JSON 직렬화된 입력 매개변수를 출력합니다.
TUI 모드: Worker 아키텍처 상세 분석
사용자가 인수 없이 opencode를 실행하면 TUI 모드가 시작됩니다. TS 버전의 TUI 아키텍처는 Go 버전과 근본적으로 다릅니다:
- Go 버전: 인프로세스에서
*app.App을 직접 보유하고, Bubble Tea의program.Send()를 통해 이벤트를 주입 - TS 버전: UI 프로세스(Ink/React)와 Server 프로세스가 분리되어 있으며, Worker가 SSE를 통해 브릿징
Worker 핵심 로직 (cmd/tui/worker.ts, ~175줄):
// Worker 시작 시퀀스 (간소화)
async function startWorker() {
// 1. 프로젝트 인스턴스 초기화
await Instance.provide({ directory: workdir })
// 2. SSE 이벤트 스트림 연결 구축
const eventStream = await startEventStream(serverURL)
// 3. 모든 Bus 이벤트를 구독하고 UI로 전달
Bus.subscribeAll((event) => {
Rpc.emit(event.type, event.data) // RPC를 통해 Ink 렌더링 스레드로 전달
})
// 4. Agent 세션 시작
// ... 세션 관리 로직
}
이벤트 스트림 브릿징 메커니즘:
Server 프로세스 Worker 스레드 Ink UI (React)
| | |
| Bus.publish(event) | |
| --------------------------> | |
| | Rpc.emit(eventType, data) |
| | --------------------------> |
| | | React 컴포넌트 업데이트
| | | 터미널 재렌더링
Worker는 Bus.subscribeAll()을 통해 모든 이벤트(메시지 업데이트, 도구 상태, 세션 변경 등)를 구독한 다음, Rpc.emit()을 통해 Ink 렌더링 스레드로 전달합니다. Ink 컴포넌트는 Rpc.on()을 통해 이벤트를 수신하고 React 상태 업데이트를 트리거합니다.
자동 재연결 메커니즘:
// SSE 연결이 끊어지면 자동 재연결
eventStream.on("close", () => {
setTimeout(() => {
startEventStream(serverURL) // 250ms 지연 후 재시도
}, 250)
})
Worker는 SSE 연결이 끊어진 후 250ms 지연으로 자동 재연결합니다. 이 지연은 네트워크 지터 중 재연결 스톰을 방지하면서도 충분한 반응성을 유지합니다(사용자는 250ms의 중단을 거의 느끼지 못합니다).
인스턴스 정리:
// 인스턴스 폐기 이벤트 수신, 리소스 정리 트리거
Bus.subscribe(Bus.InstanceDisposed, () => {
settle() // 진행 중인 모든 작업 완료
Instance.disposeAll() // 모든 리소스 정리
})
프로젝트 인스턴스가 파기될 때(예: 작업 디렉토리 전환), Worker는 진행 중인 모든 작업을 정리하고, SSE 연결을 닫으며, Storage와 Provider 리소스를 해제합니다.
호출 체인 예제
체인 1: 비대화형 모드로 파일 편집
1. opencode run "refactor the auth module" --file src/auth.ts
|-- bootstrap(cwd) -> Instance.provide
|-- OpencodeClient 연결 -> SSE 이벤트 스트림
|-- 파일 읽기: src/auth.ts -> fileParts에 추가
|-- 세션 생성 또는 계속
`-- 사용자 메시지 + 파일 내용 -> Server로 전송
2. SSE 이벤트 스트림 수신:
|-- text-delta -> 터미널에 직접 출력
|-- tool-call (glob) -> renderGlob(): "📁 glob: src/auth/**"
|-- tool-call (read) -> renderRead(): "📄 read: src/auth/login.ts"
|-- tool-call (edit) -> renderEdit(): "✏️ edit: src/auth/login.ts" + diff 표시
|-- tool-call (bash) -> renderBash(): "⚡ npm test" + 출력
`-- tool-result -> 완료 상태 표시
3. 스트림 완료 -> 프로세스 종료 (exit code 0)
체인 2: TUI 모드에서 Agent와 대화
1. opencode (인수 없음)
|-- TUI 모드 시작
|-- Worker 스레드 시작
| |-- Instance.provide
| |-- SSE 연결
| `-- Bus.subscribeAll -> Rpc.emit
|-- Ink UI 렌더링
`-- 사용자 입력 대기
2. 사용자가 "analyze the codebase" 입력
|-- Worker -> Server로 메시지 전송
|-- SSE 이벤트 수신:
| |-- Bus: MessageUpdated -> Rpc.emit -> Ink 업데이트
| |-- Bus: PartUpdated -> Rpc.emit -> 도구 상태 표시
| `-- Bus: StatusChanged -> Rpc.emit -> busy/idle 표시
`-- Agent 응답 완료 -> idle 상태로 전환
3. 사용자가 Ctrl+C로 종료
|-- Worker 정리: SSE 연결 닫기
|-- Instance.disposeAll()
`-- 프로세스 종료
설계 트레이드오프
| 결정 | 근거 |
|---|---|
| Cobra 대신 Yargs | TypeScript 생태계에서 Yargs가 사실상의 표준 명령줄 파싱 프레임워크. Cobra는 Go 전용이며, Yargs는 동적 빌더 체인, 자동 도움말 생성, TypeScript 타입 정의를 제공 |
| 인프로세스 대신 SSE 클라이언트-서버 아키텍처 | CLI를 순수 클라이언트로 만들어 비즈니스 로직을 Server로 완전히 격리. “로컬 편집, 원격 추론” 워크플로우가 가능하며, 단일 Server에 여러 CLI/TUI/IDE가 연결 가능 |
| 20개 이상의 도구 전용 렌더링 함수 | 각 도구의 의미론적 차이(예: glob은 패턴+일치 수, edit은 diff, bash는 명령+출력)에 맞춘 정보 밀도 제공. 제너릭 렌더러보다 사용자 경험이 훨씬 좋음 |
| UI.inline() / UI.block() 2계층 출력 모델 | 간결한 상태 힌트(inline)와 상세한 출력 블록(block)의 명확한 분리. 단일 렌더링 모델보다 유연하며 터미널 공간을 효율적으로 사용 |
JSON 출력 형식 (--format json) | CI/CD 파이프라인 및 스크립트 통합을 위해 구조화된 이벤트 스트림 제공. NDJSON(줄바꿈 구분 JSON) 형식으로 스트리밍 파싱이 용이 |
| 자동 재연결 (250ms 지연) | 네트워크 지터 시 재연결 스톰을 방지하면서도 충분한 반응성 유지. 지수 백오프보다 단순하고 사용자 체감 지연이 짧음 |
--dangerously-skip-permissions 명시적 명칭 | ”위험한” 작업임을 이름만으로 명확히 전달. CI/CD 자동화에 필요하지만, 일상적 사용에서는 권장하지 않음을 이름으로 표현 |
다른 모듈과의 관계
- Instance: 모든 서브커맨드는
bootstrap()을 통해Instance.provide()를 호출하여 프로젝트 컨텍스트를 초기화합니다. Worker의 수명 주기는 인스턴스의 수명 주기와 바인딩됩니다 - Server (
src/server/): CLI는 독립적인 Server 프로세스와 SSE를 통해 통신합니다.opencode serve로 시작된 서버에 원격으로 연결(--attach)할 수 있습니다 - Bus: Worker는
Bus.subscribeAll()을 통해 모든 이벤트를 구독하며, 이를 통해 Agent 상태 변경, 메시지 업데이트, 도구 실행 이벤트를 수신합니다 - Storage: 서브커맨드(session, agent 등)는
OpencodeClient를 통해 Server의 Storage API에 접근합니다 - Agent:
--agent매개변수를 통해 실행할 Agent를 지정할 수 있으며, Agent 목록은agent서브커맨드로 조회합니다 - MCP:
mcp서브커맨드는 MCP 서버의 연결 상태를 관리하며, Server의 MCP 모듈과 간접적으로 상호작용합니다 - Config:
config서브커맨드를 통해 프로젝트 설정을 관리합니다.bootstrap()중 설정이 자동으로 로드됩니다