This guide provides comprehensive information for developers working on the OpenCode project. It covers development setup, code conventions, testing strategies, and contribution guidelines.
- Go: Version 1.24.0 or higher
- SQLite: For database operations (usually system-provided)
- Git: For version control
# Clone the repository
git clone https://github.com/opencode-ai/opencode.git
cd opencode
# Build the application
go build -o opencode
# Run the application
./opencodeBased on the project's OpenCode.md file:
# Build
go build -o opencode ./main.go
# Lint/Check
go vet ./...
# Test all packages
go test ./...
# Test single package
go test -v ./internal/tui/theme/...
# Test specific test
go test -v ./internal/tui/theme/... -run TestThemeRegistration
# Generate database code
sqlc generate
# Snapshot build (for releases)
goreleaser build --clean --snapshot --skip validateSet up API keys for LLM providers you want to test:
export ANTHROPIC_API_KEY="your-key"
export OPENAI_API_KEY="your-key"
export GEMINI_API_KEY="your-key"
export GITHUB_TOKEN="your-token" # For Copilot
export GROQ_API_KEY="your-key"Follow Go standards with clear separation:
import (
// Standard library first
"context"
"fmt"
"os"
// External packages second
"github.com/spf13/cobra"
"github.com/charmbracelet/bubbletea"
// Internal packages third
"github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/config"
)- Exported functions/types: PascalCase (
ConfigManager,LoadConfig) - Unexported functions/types: camelCase (
configManager,loadConfig) - Constants: UPPER_SNAKE_CASE (
MAX_TOKENS,DEFAULT_MODEL) - Interfaces: Use descriptive names, often ending in -er (
Provider,Service)
Use structured error handling with context:
// Good
func processFile(path string) error {
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("failed to access file %s: %w", path, err)
}
// ... processing logic
return nil
}
// Context with errors
func runAgent(ctx context.Context, prompt string) error {
select {
case <-ctx.Done():
return fmt.Errorf("agent execution cancelled: %w", ctx.Err())
default:
// ... agent logic
}
return nil
}Use SQLC for type-safe database operations:
// Generated by SQLC
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
// ... generated code
}
// Usage in service
func (s *SessionService) CreateSession(ctx context.Context, title string) (*Session, error) {
session, err := s.queries.CreateSession(ctx, CreateSessionParams{
Title: title,
CreatedAt: time.Now(),
})
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
return &session, nil
}Always pass context.Context as the first parameter:
func (p *Provider) GenerateCompletion(ctx context.Context, params GenerationParams) (<-chan ProviderEvent, error) {
// Use context for cancellation and timeouts
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
// ... implementation
}
}Each major component follows the service pattern:
type Service interface {
Method(ctx context.Context, params SomeParams) (Result, error)
Subscribe(ctx context.Context) <-chan pubsub.Event[EventType]
}
type serviceImpl struct {
db *sql.DB
config *config.Config
logger *logging.Logger
}
func NewService(db *sql.DB, config *config.Config) Service {
return &serviceImpl{
db: db,
config: config,
logger: logging.NewLogger("service-name"),
}
}For LLM providers, implement the common interface:
type Provider interface {
GenerateCompletion(ctx context.Context, params GenerationParams) (<-chan ProviderEvent, error)
GetModels() []models.Model
SupportsModel(modelID models.ModelID) bool
}
type anthropicProvider struct {
client *anthropic.Client
config ProviderConfig
}
func (p *anthropicProvider) GenerateCompletion(ctx context.Context, params GenerationParams) (<-chan ProviderEvent, error) {
eventChan := make(chan ProviderEvent, 100)
go func() {
defer close(eventChan)
// ... streaming implementation
}()
return eventChan, nil
}Tools follow a consistent interface:
type BaseTool interface {
Info() ToolInfo
Run(ctx context.Context, params ToolCall) (ToolResponse, error)
}
type FileTool struct {
permissions permission.Service
}
func (t *FileTool) Info() ToolInfo {
return ToolInfo{
Name: "write",
Description: "Write content to a file",
Parameters: map[string]interface{}{
"file_path": map[string]interface{}{
"type": "string",
"description": "Path to the file to write",
},
"content": map[string]interface{}{
"type": "string",
"description": "Content to write to the file",
},
},
Required: []string{"file_path", "content"},
}
}
func (t *FileTool) Run(ctx context.Context, params ToolCall) (ToolResponse, error) {
// Permission check
if !t.permissions.HasPermission(ctx, "file.write") {
return ToolResponse{}, ErrPermissionDenied
}
// Implementation
// ...
return ToolResponse{
Success: true,
Content: "File written successfully",
}, nil
}Place tests in _test.go files alongside source files:
func TestSessionCreation(t *testing.T) {
// Setup
db := setupTestDB(t)
service := NewSessionService(db, testConfig())
// Test
session, err := service.CreateSession(context.Background(), "Test Session")
// Assertions
assert.NoError(t, err)
assert.NotNil(t, session)
assert.Equal(t, "Test Session", session.Title)
}
func TestProviderSelection(t *testing.T) {
tests := []struct {
name string
config ProviderConfig
expected models.ModelProvider
wantErr bool
}{
{
name: "anthropic preferred",
config: ProviderConfig{
Providers: map[models.ModelProvider]Provider{
models.ProviderAnthropic: {Disabled: false},
models.ProviderOpenAI: {Disabled: false},
},
},
expected: models.ProviderAnthropic,
wantErr: false,
},
// ... more test cases
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := SelectProvider(tt.config)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}For components that interact with external services:
func TestLSPIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Setup LSP client
client, err := lsp.NewClient("gopls")
require.NoError(t, err)
defer client.Shutdown()
// Test diagnostics
diagnostics, err := client.GetDiagnostics(context.Background(), "test.go")
assert.NoError(t, err)
assert.IsType(t, []lsp.Diagnostic{}, diagnostics)
}For TUI components, test the underlying logic:
func TestChatMessage(t *testing.T) {
msg := NewChatMessage("user", "Hello, world!")
// Test rendering
rendered := msg.View()
assert.Contains(t, rendered, "Hello, world!")
// Test updates
msg.SetContent("Updated content")
updated := msg.View()
assert.Contains(t, updated, "Updated content")
}Use Goose for database migrations:
# Create new migration
goose -dir internal/db/migrations create add_new_table sql
# Apply migrations
goose -dir internal/db/migrations sqlite3 ./opencode.db up
# Rollback migration
goose -dir internal/db/migrations sqlite3 ./opencode.db downDefine queries in internal/db/sql/ directory:
-- name: CreateSession :one
INSERT INTO sessions (id, title, created_at, updated_at, model_provider, model_id)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetSessionByID :one
SELECT * FROM sessions WHERE id = ? LIMIT 1;
-- name: ListSessions :many
SELECT * FROM sessions ORDER BY updated_at DESC LIMIT ? OFFSET ?;Generate Go code:
sqlc generate- Create provider file:
internal/llm/provider/newprovider.go
type NewProvider struct {
client APIClient
config ProviderConfig
}
func NewNewProvider(config ProviderConfig) Provider {
return &NewProvider{
client: NewAPIClient(config.APIKey),
config: config,
}
}
func (p *NewProvider) GenerateCompletion(ctx context.Context, params GenerationParams) (<-chan ProviderEvent, error) {
eventChan := make(chan ProviderEvent, 100)
go func() {
defer close(eventChan)
// Streaming implementation
stream, err := p.client.CreateChatCompletionStream(ctx, params)
if err != nil {
eventChan <- ProviderEvent{Type: EventError, Error: err}
return
}
for {
response, err := stream.Recv()
if err == io.EOF {
eventChan <- ProviderEvent{Type: EventDone}
break
}
if err != nil {
eventChan <- ProviderEvent{Type: EventError, Error: err}
break
}
eventChan <- ProviderEvent{
Type: EventContentDelta,
Content: response.Choices[0].Delta.Content,
}
}
}()
return eventChan, nil
}- Add models:
internal/llm/models/newprovider.go
func init() {
// Register models with the global registry
registerModel(ModelID("newprovider.model-name"), Model{
ID: "newprovider.model-name",
Name: "New Provider Model",
Provider: ProviderNewProvider,
InputCost: 0.001, // per 1k tokens
OutputCost: 0.002, // per 1k tokens
ContextWindow: 32000,
Capabilities: ModelCapabilities{
SupportsTools: true,
SupportsVision: false,
SupportsReasoning: false,
},
})
}- Register provider: Add to provider registry in the appropriate initialization code.
- Create tool file:
internal/llm/tools/newtool.go
type NewTool struct {
permissions permission.Service
}
func NewNewTool(permissions permission.Service) BaseTool {
return &NewTool{permissions: permissions}
}
func (t *NewTool) Info() ToolInfo {
return ToolInfo{
Name: "new_tool",
Description: "Description of what the tool does",
Parameters: map[string]interface{}{
"param1": map[string]interface{}{
"type": "string",
"description": "Description of parameter",
},
},
Required: []string{"param1"},
}
}
func (t *NewTool) Run(ctx context.Context, params ToolCall) (ToolResponse, error) {
// Permission check
permitted, err := t.permissions.RequestPermission(ctx, permission.Request{
Tool: "new_tool",
Description: "Execute new tool operation",
Sensitive: false,
})
if err != nil {
return ToolResponse{}, err
}
if !permitted {
return ToolResponse{}, ErrPermissionDenied
}
// Extract parameters
param1, ok := params.Parameters["param1"].(string)
if !ok {
return ToolResponse{}, fmt.Errorf("param1 must be a string")
}
// Tool implementation
result, err := performToolOperation(param1)
if err != nil {
return ToolResponse{}, fmt.Errorf("tool operation failed: %w", err)
}
return ToolResponse{
Success: true,
Content: result,
}, nil
}- Register tool: Add to tool registry in
internal/llm/agent/tools.go
- Component structure:
type NewComponent struct {
width int
height int
theme theme.Theme
model ComponentModel
}
func NewNewComponent(theme theme.Theme) *NewComponent {
return &NewComponent{
theme: theme,
model: initComponentModel(),
}
}
func (c *NewComponent) Init() tea.Cmd {
return nil
}
func (c *NewComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
c.width = msg.Width
c.height = msg.Height
case tea.KeyMsg:
return c.handleKeyPress(msg)
}
return c, nil
}
func (c *NewComponent) View() string {
return c.renderComponent()
}- Theme integration:
func (c *NewComponent) renderComponent() string {
style := lipgloss.NewStyle().
Foreground(c.theme.Colors().Text).
Background(c.theme.Colors().Background).
Border(lipgloss.RoundedBorder()).
BorderForeground(c.theme.Colors().Border)
return style.Render(c.model.content)
}- Create theme file:
internal/tui/theme/newtheme.go
type NewTheme struct {
colors ThemeColors
}
func NewNewTheme() Theme {
return &NewTheme{
colors: ThemeColors{
Background: lipgloss.Color("#000000"),
Text: lipgloss.Color("#ffffff"),
Primary: lipgloss.Color("#ff0000"),
Secondary: lipgloss.Color("#00ff00"),
Border: lipgloss.Color("#333333"),
// ... more colors
},
}
}
func (t *NewTheme) Name() string {
return "newtheme"
}
func (t *NewTheme) Colors() ThemeColors {
return t.colors
}- Register theme: Add to theme manager registration.
Run with debug logging:
./opencode -dEnable LSP-specific debugging:
{
"debugLSP": true,
"lsp": {
"go": {
"command": "gopls",
"args": ["-logfile", "/tmp/gopls.log", "-v"]
}
}
}Check database state:
sqlite3 .opencode/opencode.db ".tables"
sqlite3 .opencode/opencode.db "SELECT * FROM sessions LIMIT 5;"Test provider configuration:
./opencode -p "test prompt" -f json- Fork and branch: Create a feature branch from
main - Implement changes: Follow code style and add tests
- Test thoroughly: Run all tests and manual testing
- Update documentation: Update relevant documentation
- Submit PR: Create pull request with clear description
Follow conventional commit format:
feat: add support for new LLM provider
fix: resolve memory leak in message streaming
docs: update architecture documentation
test: add integration tests for LSP client
refactor: simplify provider configuration
- Code follows established patterns and conventions
- Tests added for new functionality
- Documentation updated
- Error handling is comprehensive
- Performance implications considered
- Security implications reviewed
- Backward compatibility maintained
Versions follow semantic versioning (semver):
- Major: Breaking changes
- Minor: New features, backward compatible
- Patch: Bug fixes, backward compatible
- Update version: Update
internal/version/version.go - Update changelog: Document changes
- Tag release:
git tag v1.2.3 - Push tag:
git push origin v1.2.3 - GoReleaser: Automated release via GitHub Actions
OpenCode is distributed through:
- GitHub Releases: Binaries for multiple platforms
- Homebrew: macOS and Linux package manager
- AUR: Arch Linux User Repository
- Go Install: Direct Go installation
- Bubble Tea: TUI framework - https://github.com/charmbracelet/bubbletea
- Cobra: CLI framework - https://github.com/spf13/cobra
- Viper: Configuration - https://github.com/spf13/viper
- SQLC: SQL code generation - https://sqlc.dev/
- Goose: Database migrations - https://github.com/pressly/goose
- OpenAI: https://platform.openai.com/docs
- Anthropic: https://docs.anthropic.com/
- Google AI: https://ai.google.dev/docs
- GitHub Copilot: https://docs.github.com/en/copilot
- LSP Specification: https://microsoft.github.io/language-server-protocol/
- Language Servers: https://langserver.org/
This development guide should help both new and experienced contributors understand the OpenCode codebase and contribute effectively to the project.