CLI Source Code Analysis
Module Overview
The CLI is OpenCode’s command-line entry layer, located at packages/opencode/src/cli/. The TypeScript version uses Yargs (rather than the Go version’s Cobra) for parsing command-line arguments and subcommands, supporting two execution modes:
- TUI mode (default): Launches Ink (a React-based terminal UI rendering engine), with a background Worker thread communicating with the Server via SSE event stream, handling Agent interactions and tool executions
- Non-interactive mode (
runsubcommand): Executes a single prompt and exits, built on@opencode-ai/sdkv2’sOpencodeClientto implement complete Agent sessions with inline rendering for 20+ tool types
The fundamental difference from the Go version is: the Go version directly holds an in-process App service instance, while the TS version communicates with an independent Server process via SSE — the CLI is a pure client, with the Server carrying all business logic. This architecture enables the CLI to remotely connect to a server started with opencode serve, achieving a “local editing, remote inference” workflow.
Core responsibilities:
- Command-line argument parsing and subcommand routing (Yargs framework)
- Project instance initialization (
bootstrap->Instance.provide) - SSE event stream establishment and bidirectional communication
- Tool state rendering in non-interactive mode (20+ tool types)
- Subcommand system management (session, agent, mcp, models, providers, serve, etc.)
Key Files
| File Path | Lines | Responsibility |
|---|---|---|
src/cli/bootstrap.ts | ~17 | Project initialization wrapper: calls Instance.provide to set up project directory and configuration |
src/cli/cmd/cmd.ts | ~6 | cmd() helper function: type-safe wrapper for Yargs CommandModule |
src/cli/cmd/run.ts | ~690 | run subcommand: non-interactive mode core implementation, SSE event stream consumption, tool rendering, file attachment, permission handling |
src/cli/cmd/tui/worker.ts | ~175 | TUI Worker: SSE connection management, Agent session proxy, event forwarding to Ink UI |
src/cli/cmd/tui/event.ts | — | TUI BusEvent definitions: event protocol between Worker and Ink UI |
src/cli/cmd/session.ts | — | session subcommand: session CRUD, sharing, and archiving operations |
src/cli/cmd/agent.ts | — | agent subcommand: Agent list querying and management |
src/cli/cmd/mcp.ts | — | mcp subcommand: MCP server management |
src/cli/cmd/models.ts | — | models subcommand: list available models |
src/cli/cmd/providers.ts | — | providers subcommand: list available Providers |
src/cli/cmd/serve.ts | — | serve subcommand: start local HTTP server |
src/cli/cmd/debug/ | — | debug subcommand group: debugging tool collection |
src/cli/ui.ts | — | Terminal UI helpers: Style constants, inline()/block() formatting functions |
src/cli/logo.ts | — | Logo ASCII art: renders brand identity on startup |
src/cli/heap.ts | — | Heap snapshot: writeHeapSnapshot() for memory analysis |
src/cli/upgrade.ts | — | Auto-upgrade: detect and install new versions |
src/cli/network.ts | — | Network detection: connectivity checks and timeout handling |
src/cli/error.ts | — | Error handling: global exception capture and formatted output |
Type System
UI.Style — Terminal Style Constants
ui.ts defines unified terminal output style constants, ensuring consistent output formatting across all subcommands:
export const Style = {
TEXT_NORMAL, // Default text
TEXT_DIM, // Gray dimmed text (supplementary info)
TEXT_INFO_BOLD, // Blue bold (titles, status)
TEXT_WARNING_BOLD, // Yellow bold (warnings)
TEXT_DANGER_BOLD, // Red bold (errors, rejections)
} as const
UI Output Functions
// Single-line info display: icon + title + description
export function inline(info: {
icon: string
title: string
description?: string
}): void
// Info block with separator: title + optional output content
export function block(info: {
icon: string
title: string
description?: string
}, output?: string): void
inline() is used for compact status hints (e.g., “Agent: build”), while block() is used for scenarios that need to display detailed output content (e.g., tool execution results).
cmd() Helper Function
// cmd.ts — type-safe Yargs CommandModule wrapper
export function cmd(mod: CommandModule): CommandModule {
return mod
}
Although only 6 lines of code, the value of cmd() lies in providing a unified type signature constraint for all subcommands — each subcommand module exports through cmd(), ensuring Yargs’ CommandModule interface is correctly implemented.
Core Flow
Entry Point and Command Routing
The CLI entry point is at packages/opencode/src/node.ts, which uses Yargs to define the complete subcommand system:
opencode # Main entry (no args -> launches TUI mode)
|-- run [message] # Non-interactive mode
| |-- --continue, -c # Continue previous session
| |-- --session, -s # Specify session ID
| |-- --fork # Fork from existing session
| |-- --model, -m # Specify model (provider/model format)
| |-- --agent # Specify execution Agent
| |-- --format # Output format (default / json)
| |-- --file, -f # Attach files to context
| |-- --attach # Connect to remote server
| |-- --thinking # Show reasoning process
| `-- --dangerously-skip-permissions # Skip permission confirmations
|-- session # Session management
| |-- list # List all sessions
| |-- create # Create new session
| |-- delete # Delete session
| |-- share # Share session
| `-- archive # Archive session
|-- agent # Agent management (list built-in and custom Agents)
|-- mcp # MCP server management
|-- models # Model list (grouped by Provider)
|-- providers # Provider list
|-- serve # Start local HTTP server
|-- config # Configuration management
`-- debug # Debug tool group
Each subcommand is wrapped with cmd() and registered with Yargs. For example, the session subcommand uses a nested Yargs builder internally to define five second-level subcommands: list, create, delete, share, and archive.
Project Initialization: bootstrap()
bootstrap.ts is the common prerequisite for all subcommands that need project context:
// Simplified bootstrap implementation
export async function bootstrap(
dir: string, // Project working directory
cb: () => Promise<void> // Callback after initialization completes
) {
await Instance.provide({ directory: dir })
await cb()
}
Instance.provide initializes the complete project instance based on the given directory — loading configuration, connecting to Storage, registering Providers, discovering MCP servers, etc. bootstrap() wraps this initialization process as a unified entry point, so subcommands only need to care about their own business logic.
Non-Interactive Mode: run Command in Detail
run.ts (~690 lines) is the most complex file in the CLI module, implementing a complete non-interactive Agent session. The core flow is divided into four phases:
Phase 1: Initialization and Session Preparation
// Simplified initialization sequence
await bootstrap(dir, async () => {
// 1. Parse command-line arguments
const message = argv._[0] as string // User message
const sessionID = argv.session // Specified session ID
const continue_ = argv.continue // Continue previous session
const fork = argv.fork // Fork session
const model = argv.model // Model selection
const agent = argv.agent // Agent selection
const files = argv.file as string[] ?? [] // Attached files
const format = argv.format ?? "default" // Output format
// 2. Create SSE client connection
const client = new OpencodeClient(/* ... */)
})
Phase 2: File Attachment and Session Forking
The run command supports injecting file contents into user messages via the --file parameter:
// File attachment logic: includes file contents as message context
const fileParts: Part[] = []
for (const filePath of files) {
const content = await readFile(filePath, "utf-8")
fileParts.push({
type: "text",
text: `--- ${filePath} ---\n${content}`,
})
}
// File contents are prepended to the user message
The session forking mechanism allows users to create new branches from existing sessions:
--fork <sessionID>: Creates a new session based on the specified session’s message history--fork+--continue: Forks and continues the conversation in the new session--continue(without--fork): Directly continues the most recent session
Phase 3: SSE Event Stream Consumption and Tool Rendering
This is the core of the run command — receiving the Agent’s streaming responses in real-time via SSE connection, and calling the corresponding rendering function based on event type:
// Main event stream consumption loop (simplified)
for await (const event of client.events()) {
switch (event.type) {
case "message":
// Text output -> print directly to terminal
process.stdout.write(event.content)
break
case "tool":
// Tool event -> call corresponding rendering function
renderTool(event.tool, event.state)
break
case "error":
// Error -> formatted output
UI.inline({
icon: "✗",
title: event.name,
description: event.message,
})
break
case "permission":
// Permission request -> auto-deny in non-interactive mode
if (!dangerouslySkipPermissions) {
UI.inline({
icon: "🔒",
title: "Permission denied",
description: event.tool,
})
}
break
}
}
Phase 4: Output Formatting
The run command supports two output formats:
--format default: Human-readable terminal output, usingUI.inline()andUI.block()to format tool status--format json: Structured JSON event stream, one JSON object per line, suitable for script integration and CI/CD scenarios
Tool Rendering System
run.ts contains dedicated rendering functions for 20+ tool types, each displaying different information based on its semantics:
// glob tool: display search pattern, match count, root directory
function renderGlob(tool: ToolEvent) {
UI.inline({
icon: "📁",
title: `glob: ${tool.input.pattern}`,
description: `${tool.matchCount} matches in ${tool.input.root}`,
})
}
// grep tool: display search pattern, match count
function renderGrep(tool: ToolEvent) {
UI.inline({
icon: "🔍",
title: `grep: ${tool.input.pattern}`,
description: `${tool.matchCount} matches`,
})
}
// read tool: display file path and parameters
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 tool: diff display
function renderEdit(tool: ToolEvent) {
UI.block({
icon: "✏️",
title: `edit: ${tool.input.filePath}`,
}, tool.diff) // Display specific diff content
}
// bash tool: command + output
function renderBash(tool: ToolEvent) {
UI.block({
icon: "⚡",
title: tool.input.command,
}, tool.output)
}
// task tool: sub-Agent task status
function renderTask(tool: ToolEvent) {
UI.inline({
icon: "🤖",
title: `task: ${tool.input.description}`,
description: tool.state, // pending / running / completed / error
})
}
Additionally, there are rendering functions for webfetch, codesearch, websearch, skill, todo, and other tools. For unrecognized tool types, a fallback renderer is used — printing the tool name and JSON-serialized input parameters.
TUI Mode: Worker Architecture in Detail
When the user runs opencode without any arguments, TUI mode is entered. The TS version’s TUI architecture differs fundamentally from the Go version:
- Go version: Directly holds
*app.Appin-process, injecting events via Bubble Tea’sprogram.Send() - TS version: UI process (Ink/React) and Server process are separated, with the Worker bridging them via SSE
Worker Core Logic (cmd/tui/worker.ts, ~175 lines):
// Worker startup sequence (simplified)
async function startWorker() {
// 1. Initialize project instance
await Instance.provide({ directory: workdir })
// 2. Establish SSE event stream connection
const eventStream = await startEventStream(serverURL)
// 3. Subscribe to all Bus events and forward to UI
Bus.subscribeAll((event) => {
Rpc.emit(event.type, event.data) // Forward to Ink rendering thread via RPC
})
// 4. Start Agent session
// ... session management logic
}
Event Stream Bridging Mechanism:
Server process Worker thread Ink UI (React)
| | |
| Bus.publish(event) | |
| --------------------------> | |
| | Rpc.emit(eventType, data) |
| | --------------------------> |
| | | React component update
| | | Re-render terminal
The Worker subscribes to all events via Bus.subscribeAll() (message updates, tool status, session changes, etc.), then forwards them to the Ink rendering thread via Rpc.emit(). Ink components listen for events via Rpc.on() and trigger React state updates.
Auto-Reconnect Mechanism:
// Auto-reconnect when SSE connection drops
eventStream.on("close", () => {
setTimeout(() => {
startEventStream(serverURL) // Retry after 250ms delay
}, 250)
})
The Worker automatically reconnects after a 250ms delay when the SSE connection drops. This delay avoids reconnection storms during network jitter while being short enough to maintain a smooth user experience.
Instance Cleanup:
// Listen for instance disposal events, trigger resource cleanup
Bus.subscribe(Bus.InstanceDisposed, () => {
settle() // Complete all in-progress operations
Instance.disposeAll() // Clean up all resources
})
When the project instance is destroyed (e.g., switching working directories), the Worker cleans up all in-progress operations, closes SSE connections, and releases Storage and Provider resources.
Error Handling
CLI-level error handling covers three layers:
1. Global Exception Capture
// error.ts — process-level exception fallback
process.on("unhandledRejection", (error) => {
UI.inline({
icon: "✗",
title: "Unexpected error",
description: String(error),
})
process.exit(1)
})
process.on("uncaughtException", (error) => {
UI.inline({
icon: "✗",
title: "Uncaught exception",
description: String(error),
})
process.exit(1)
})
2. Session Error Events
// run.ts — Session-level error handling
Bus.subscribe(Session.error, (event) => {
const { name, message } = extractError(event.error)
UI.inline({
icon: "✗",
title: name,
description: message,
})
})
3. Permission Handling
In non-interactive mode, since no user is present to confirm permission requests, the default strategy is to automatically deny all permission requests:
// run.ts — permission event handling
Bus.subscribe(Permission.asked, (event) => {
if (dangerouslySkipPermissions) {
// Auto-approve under --dangerously-skip-permissions flag
event.respond(true)
} else {
// Default: auto-deny
event.respond(false)
}
})
The --dangerously-skip-permissions flag is suitable for fully automated scenarios like CI/CD, skipping all permission confirmations. The “dangerously” prefix in the flag name explicitly signals the risk of this operation.
Heap Snapshots
heap.ts provides memory analysis support:
// heap.ts — auto-write heap snapshot on startup
import { writeHeapSnapshot } from "node:v8"
// Write heap snapshot to project directory
writeHeapSnapshot(path.join(workdir, "server.heapsnapshot"))
Heap snapshots are used for troubleshooting memory leak issues. The generated server.heapsnapshot file can be loaded and analyzed in Chrome DevTools’ Memory panel.
Call Chain Examples
Chain 1: Non-Interactive Mode — User Runs opencode run "read main.ts"
User executes: opencode run "read main.ts"
|
v
Yargs parses arguments -> matches "run" subcommand
|
v
run.ts handler executes:
|-- bootstrap(cwd, callback)
| `-- Instance.provide({ directory: cwd })
| |-- Load Config (opencode.json)
| |-- Initialize Storage
| |-- Register Providers (load API Keys)
| `-- Discover MCP servers
|
|-- Create or restore Session
| |-- --continue? -> Session.get(recent)
| |-- --fork? -> Session.create({ parentID: fork })
| `-- Default -> Session.create()
|
|-- Construct user message
| |-- text: "read main.ts"
| |-- --file attached? -> append file contents
| `-- --model? -> override default model
|
|-- Create OpencodeClient -> SSE connection to Server
|
`-- Event stream consumption loop:
|-- message.text -> process.stdout.write()
|-- tool.read -> renderRead({ filePath, offset, limit })
|-- tool.read.done -> block("edit", diff)
|-- tool.bash -> block("⚡", command + output)
|-- error -> inline("✗", error.name, message)
`-- session.end -> break (exit loop)
Process exits, output sent to stdout
Chain 2: TUI Mode — Worker Event Forwarding
User runs: opencode (no arguments)
|
v
Yargs -> no matching subcommand -> launch TUI mode
|
v
Ink rendering engine starts (React component tree)
|
v
Worker thread starts:
|-- Instance.provide({ directory: cwd })
|-- startEventStream(serverURL)
| `-- SSE connection established
|
`-- Bus.subscribeAll() registers global event listeners
|
v
User types message in Ink UI:
|-- Rpc.call("session.prompt", { message })
| `-- Worker receives -> calls Session.prompt()
|
|-- Server processes message:
| |-- SessionPrompt.loop()
| |-- LLM.stream() -> Vercel AI SDK streamText()
| |-- Bus.publish(MessageV2.Event.PartUpdated)
| `-- Bus.publish(MessageV2.Event.Updated)
|
`-- Worker event forwarding:
|-- Bus -> Rpc.emit("message.updated", data)
| `-- Ink component setState() -> re-render message list
|
`-- Bus -> Rpc.emit("tool.state", data)
`-- Ink component setState() -> update tool status indicator
Chain 3: Session Forking and Continuation
User executes: opencode run "continue editing" --fork abc123 --model anthropic/claude-sonnet-4
|
v
run.ts parses arguments:
|-- message = "continue editing"
|-- fork = "abc123"
|-- model = { providerID: "anthropic", modelID: "claude-sonnet-4" }
|
v
Session.create({ parentID: "abc123" })
|-- Copy message history from parent session abc123
|-- New session gets independent ID, does not affect parent session
|
v
Model override:
`-- Provider.getModel("anthropic", "claude-sonnet-4")
`-- Subsequent LLM calls use the specified model
|
v
Normal event stream consumption (same as Chain 1)
Design Tradeoffs
| Decision | Rationale |
|---|---|
| Yargs instead of Commander / custom solution | Yargs provides mature subcommand nesting, argument validation, auto-generated help, and a type-safe builder pattern. The nested subcommand system (session -> list/create/delete/share/archive) is naturally expressed in Yargs |
| SSE instead of in-process calls | The TS version’s CLI-Server separation architecture enables the CLI to remotely connect to a server started with opencode serve. SSE serves as the transport protocol for unidirectional event streaming, combined with OpencodeClient’s RPC calls for bidirectional communication |
| Ink (React) instead of Bubble Tea | Go’s Bubble Tea uses Elm Architecture (Model-Update-View), while the TS version uses Ink + React — React’s component model is better suited for building complex terminal UIs, and has a larger developer ecosystem |
| Non-interactive mode defaults to denying permissions | With no user present to confirm permission requests, auto-deny is the safest default strategy. --dangerously-skip-permissions provides an override option with an explicit danger flag |
| 20+ tool types each with dedicated rendering | Each tool has different semantics (glob shows match count, edit shows diff, bash shows command output). Dedicated rendering functions provide better readability than a generic renderer. The fallback function ensures unrecognized tools are still displayed properly |
| Worker thread instead of main thread | SSE event stream consumption and Bus event subscription run on the Worker thread, avoiding blocking Ink’s React rendering loop. Ink communicates with the Worker via Rpc.emit/Rpc.on, keeping the UI responsive |
| 250ms reconnect delay | Avoids reconnection storms during network jitter (too short would overload the server), while maintaining sufficient responsiveness (users barely notice a 250ms interruption) |
bootstrap() as unified initialization entry point | All subcommands requiring project context share the same initialization logic (Config loading, Provider registration, MCP discovery), avoiding duplicate code and inconsistent state |
Relationships with Other Modules
- Instance (
src/project/):bootstrap()callsInstance.provide()to initialize the project instance; the Worker callsInstance.disposeAll()for cleanup. Instance is the bridge between the CLI and business logic - Session (
src/session/): Theruncommand operates on Sessions viaOpencodeClient(create, continue, fork); thesessionsubcommand directly calls the Session module’s CRUD interface - Agent (
src/agent/): Theruncommand’s--agentparameter specifies the execution Agent; theagentsubcommand callsAgent.list()to display available Agents. The CLI itself does not execute Agent logic — it only passes parameters - Provider (
src/provider/): Theruncommand’s--modelparameter validates model availability viaProvider.getModel(); themodelsandproviderssubcommands directly call Provider query interfaces - Bus (
src/bus/): The Worker subscribes to all events viaBus.subscribeAll(), bridging them to the Ink UI; theruncommand listens forSession.errorandPermission.askedevents via Bus - MCP (
src/mcp/): Themcpsubcommand manages MCP server start/stop;Instance.provide()automatically discovers and connects to MCP servers during initialization - Config (
src/config/):bootstrap()loads Config during initialization; theconfigsubcommand provides configuration viewing and modification interfaces. Default parameters for all subcommands (model, Agent, etc.) come from Config - Storage (
src/storage/): Session messages and state are persisted via Storage, which the CLI operates indirectly throughOpencodeClient - Server (
src/server/): Theservesubcommand starts a local HTTP server; the TUI Worker andruncommand communicate via SSE connections to the Server - UI Helpers (
cli/ui.ts,cli/logo.ts): All subcommands share unified terminal output formatting, ensuring brand consistency and readability