Skip to content

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 (run subcommand): Executes a single prompt and exits, built on @opencode-ai/sdk v2’s OpencodeClient to 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 PathLinesResponsibility
src/cli/bootstrap.ts~17Project initialization wrapper: calls Instance.provide to set up project directory and configuration
src/cli/cmd/cmd.ts~6cmd() helper function: type-safe wrapper for Yargs CommandModule
src/cli/cmd/run.ts~690run subcommand: non-interactive mode core implementation, SSE event stream consumption, tool rendering, file attachment, permission handling
src/cli/cmd/tui/worker.ts~175TUI Worker: SSE connection management, Agent session proxy, event forwarding to Ink UI
src/cli/cmd/tui/event.tsTUI BusEvent definitions: event protocol between Worker and Ink UI
src/cli/cmd/session.tssession subcommand: session CRUD, sharing, and archiving operations
src/cli/cmd/agent.tsagent subcommand: Agent list querying and management
src/cli/cmd/mcp.tsmcp subcommand: MCP server management
src/cli/cmd/models.tsmodels subcommand: list available models
src/cli/cmd/providers.tsproviders subcommand: list available Providers
src/cli/cmd/serve.tsserve subcommand: start local HTTP server
src/cli/cmd/debug/debug subcommand group: debugging tool collection
src/cli/ui.tsTerminal UI helpers: Style constants, inline()/block() formatting functions
src/cli/logo.tsLogo ASCII art: renders brand identity on startup
src/cli/heap.tsHeap snapshot: writeHeapSnapshot() for memory analysis
src/cli/upgrade.tsAuto-upgrade: detect and install new versions
src/cli/network.tsNetwork detection: connectivity checks and timeout handling
src/cli/error.tsError 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, using UI.inline() and UI.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.App in-process, injecting events via Bubble Tea’s program.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

DecisionRationale
Yargs instead of Commander / custom solutionYargs 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 callsThe 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 TeaGo’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 permissionsWith 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 renderingEach 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 threadSSE 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 delayAvoids 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 pointAll 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() calls Instance.provide() to initialize the project instance; the Worker calls Instance.disposeAll() for cleanup. Instance is the bridge between the CLI and business logic
  • Session (src/session/): The run command operates on Sessions via OpencodeClient (create, continue, fork); the session subcommand directly calls the Session module’s CRUD interface
  • Agent (src/agent/): The run command’s --agent parameter specifies the execution Agent; the agent subcommand calls Agent.list() to display available Agents. The CLI itself does not execute Agent logic — it only passes parameters
  • Provider (src/provider/): The run command’s --model parameter validates model availability via Provider.getModel(); the models and providers subcommands directly call Provider query interfaces
  • Bus (src/bus/): The Worker subscribes to all events via Bus.subscribeAll(), bridging them to the Ink UI; the run command listens for Session.error and Permission.asked events via Bus
  • MCP (src/mcp/): The mcp subcommand manages MCP server start/stop; Instance.provide() automatically discovers and connects to MCP servers during initialization
  • Config (src/config/): bootstrap() loads Config during initialization; the config subcommand 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 through OpencodeClient
  • Server (src/server/): The serve subcommand starts a local HTTP server; the TUI Worker and run command 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