Developer Guide
Everything you need to build, test, and contribute to Automergent — a terminal-native AI coding agent written in Go.
Project Overview
Automergent is an AI-powered coding agent that lives in your terminal. It provides a full-featured TUI built with the Charm v2 stack, supports multiple AI providers, and ships with 43+ built-in tools across 13 categories.
Core Technologies
| Component | Technology | Version |
|---|---|---|
| Language | Go | 1.25+ |
| Module | github.com/iSundram/Automergent | — |
| License | MIT | — |
| TUI Framework | Bubble Tea v2 | v2.0.2 |
| Styling | Lip Gloss v2 | v2.0.1 |
| Markdown Rendering | Glamour v2 | v2.0.0 |
| CLI Framework | Cobra | v1.8.1 |
| Configuration | Viper | v1.19.0 |
| AI SDK | Google GenAI SDK | v1.68.0 |
| Tree-sitter | go-tree-sitter | — |
| Database | modernc.org/sqlite | v1.57.0 |
You need Go 1.25+ with CGO enabled. Some features (tree-sitter parsing, SQLite) require CGO. On macOS, ensure Xcode command line tools are installed. On Linux, you may need gcc and the SQLite development headers.
Repository Structure
The project follows standard Go project layout with a clean separation between the CLI entrypoint, internal packages, and the documentation site.
Building
Automergent uses a Makefile with standard targets. All build commands require CGO_ENABLED=1.
Make Targets
| Command | Description |
|---|---|
make build | Build the binary to bin/automergent |
make install | Install to $GOPATH/bin |
make test | Run all tests (CGO_ENABLED=1 go test ./...) |
make lint | Run golangci-lint |
make fmt | Format with gofmt + goimports |
make tidy | Run go mod tidy |
make ci | Full CI checks locally (test + vet + lint) |
make clean | Remove bin/ directory |
make release | Run GoReleaser snapshot build |
Build Flags
The build injects version metadata via ldflags:
LDFLAGS_AUTOMERGENT := -s -w \
-X 'github.com/iSundram/Automergent/internal/version.Version=$(VERSION)' \
-X 'github.com/iSundram/Automergent/internal/version.Commit=$(COMMIT)' \
-X 'github.com/iSundram/Automergent/internal/version.BuildDate=$(BUILD_DATE)'
These values are read at runtime by the internal/version package and displayed via automergent --version.
Quick Start for Contributors
Clone and enter the repo:
git clone https://github.com/iSundram/Automergent.git
cd Automergent
Build:
make build
Run tests:
make test
Run full CI checks:
make ci
CI/CD Pipeline
Automergent uses GitHub Actions for continuous integration and GoReleaser for release distribution.
Continuous Integration
CI runs on every pull request and push to main. The pipeline performs:
- Tests —
CGO_ENABLED=1 go test ./... - Vet —
go vet ./... - Lint —
golangci-lint run ./...
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- run: CGO_ENABLED=1 go test ./...
- run: go vet ./...
- run: golangci-lint run ./...
Release Pipeline
Releases are triggered by pushing a v* tag (e.g., v1.0.0). GoReleaser builds binaries for:
| Platform | Architecture | Output |
|---|---|---|
| Linux | amd64 | automergent_linux_amd64 |
| Linux | arm64 | automergent_linux_arm64 |
| macOS | amd64 | automergent_darwin_amd64 |
name: Release
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean -f dist/goreleaser-automergent.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Testing
All tests require CGO enabled. Run the full suite with:
CGO_ENABLED=1 go test ./...
Test Patterns
The codebase uses several testing strategies:
- Table-driven tests — standard Go pattern for input/output assertions
- Mock providers — in-memory provider implementations for testing agent behavior
- Debug provider — the
internal/debugpackage provides a decorator that logs all requests/responses, useful for isolated testing - Session testing — the
internal/sessionpackage supports in-memory sessions for testing without disk I/O - Tool testing — tools implement a common interface; tests can exercise them with mock contexts and file systems
- Concurrency testing — the tool registry and agent event bus are tested for concurrent access safety
Running Specific Tests
# Run tests in a specific package
CGO_ENABLED=1 go test ./internal/tools/...
# Run tests matching a pattern
CGO_ENABLED=1 go test -run TestTool ./internal/tools/
# Run tests with verbose output
CGO_ENABLED=1 go test -v ./internal/agent/...
# Run tests with race detection
CGO_ENABLED=1 go test -race ./...
Linting
Install golangci-lint and run:
golangci-lint run ./...
The project uses the default golangci-lint configuration. CI will fail if any lint errors are found.
Key Interfaces
Automergent is built around a few core interfaces. Understanding these is essential for contributing new tools, providers, or themes.
Tool Interface
Every tool must implement the Tool interface defined in internal/tools/tool.go:
type Tool interface {
Name() string
Description() string
Schema() map[string]any
Execute(ctx context.Context, args map[string]any) (Result, error)
RequiresConfirmation(mode string) bool
IsConcurrencySafe(args map[string]any) bool
IsReadOnly(args map[string]any) bool
IsDestructive(args map[string]any) bool
EstimatedCost() ToolCost
}
| Method | Purpose | Default |
|---|---|---|
Name() | Unique tool identifier used in schemas and logs | — |
Description() | Shown to the AI model in tool schemas | — |
Schema() | JSON Schema for the tool's parameters | — |
Execute() | Run the tool with the given arguments | — |
RequiresConfirmation() | Whether this tool needs user approval before running | false |
IsConcurrencySafe() | Whether the tool can run concurrently with others | false |
IsReadOnly() | Whether the tool only reads (no side effects) | false |
IsDestructive() | Whether the tool can destroy data | false |
EstimatedCost() | Token and latency estimates for scheduling | low cost |
BaseTool Helper
Embed BaseTool to get safe defaults for the boolean methods and cost estimates:
type MyTool struct {
tools.BaseTool
}
func (t *MyTool) Name() string { return "my_tool" }
func (t *MyTool) Description() string { return "Does something useful" }
func (t *MyTool) Schema() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{...},
"required": []string{...},
}
}
func (t *MyTool) Execute(ctx context.Context, args map[string]any) (tools.Result, error) {
// implementation
}
Provider Interface
AI providers implement the Provider interface from internal/ai/provider.go:
type Provider interface {
Name() string
Complete(ctx context.Context, req CompletionRequest) (CompletionResponse, error)
Models(ctx context.Context) ([]Model, error)
TokenCount(messages []Message) (int, error)
ContextLimit() int
}
RetryObserver Interface
Providers that retry internally can implement RetryObserver to report retry attempts to the UI:
type RetryObserver interface {
SetRetryObserver(func(RetryInfo))
}
Wrapping providers (caching, debug, etc.) must forward this to the wrapped provider so retries remain visible.
Adding a New Tool
New tools are the most common contribution. Follow these steps to add one:
Step 1: Create the tool file
Add a new file in the appropriate internal/tools/<category>/ directory:
package mytool
import (
"context"
"github.com/iSundram/Automergent/internal/tools"
)
// MyTool implements tools.Tool
type MyTool struct {
tools.BaseTool
}
func (t *MyTool) Name() string { return "my_tool" }
func (t *MyTool) Description() string {
return "Description shown to the AI model"
}
func (t *MyTool) Schema() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"input": map[string]any{
"type": "string",
"description": "The input parameter",
},
},
"required": []string{"input"},
}
}
func (t *MyTool) Execute(ctx context.Context, args map[string]any) (tools.Result, error) {
input, _ := args["input"].(string)
// Tool logic here
return tools.Result{
Content: "Result text",
Summary: "Short summary for UI",
}, nil
}
Step 2: Register the tool
In cmd/automergent/main.go, add the import and register the tool in the tool registry setup block:
import (
toolsMyTool "github.com/iSundram/Automergent/internal/tools/mytool"
)
// In the tool registry setup:
reg.Register(&toolsMytool.MyTool{})
Step 3: Add a tool prompt (optional)
Add a prompt file in internal/prompt/phases/ to guide the model on when and how to use your tool.
Step 4: Test
Write tests in mytool_test.go following the table-driven test pattern:
func TestMyTool(t *testing.T) {
tool := &MyTool{}
if tool.Name() != "my_tool" {
t.Errorf("expected name my_tool, got %s", tool.Name())
}
result, err := tool.Execute(context.Background(), map[string]any{
"input": "test",
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Error("expected success")
}
}
Adding a New Provider
New AI providers implement the Provider interface and register themselves in the global provider registry.
Step 1: Create the provider package
package myprovider
import (
"context"
"github.com/iSundram/Automergent/internal/ai"
)
type Provider struct {
config ai.ProviderConfig
}
func New(cfg ai.ProviderConfig) *Provider {
return &Provider{config: cfg}
}
func (p *Provider) Name() string { return "myprovider" }
func (p *Provider) Complete(
ctx context.Context,
req ai.CompletionRequest,
) (ai.CompletionResponse, error) {
// Call your provider's API here
// Return a CompletionResponse with streaming support
}
func (p *Provider) Models(ctx context.Context) ([]ai.Model, error) {
// Return available models
}
func (p *Provider) TokenCount(messages []ai.Message) (int, error) {
// Estimate token count
}
func (p *Provider) ContextLimit() int {
return 128000 // context window size
}
Step 2: Register in the provider catalog
Add your provider to internal/config/providers_catalog.go so it appears in the config schema and auto-discovery:
// In the catalog entry:
"myprovider": {
Name: "myprovider",
DisplayName: "My Provider",
EnvKey: "MYPROVIDER_API_KEY",
Models: []string{"model-1", "model-2"},
DefaultModel: "model-1",
}
Step 3: Wire into the CLI
In cmd/automergent/main.go, add a case in the provider switch:
case "myprovider":
provider = myprovider.New(aiCfg)
Step 4: Configure
Add to your ~/.automergent/config.yaml:
provider: custom
model: model-1
providers:
custom:
apiKey: "your-api-key-here"
baseUrl: "https://your-endpoint/v1"
defaultModel: "model-1"
Adding a Theme
Automergent ships with 11 built-in themes. Each theme defines a 16-color palette and 50+ pre-built Lip Gloss styles.
Color Palette (16 slots)
| Slot | Purpose |
|---|---|
Background | Terminal background color |
Surface | Card/panel background |
Overlay | Dropdown/popover background |
Text | Primary text color |
Subtext | Secondary text |
Muted | Dimmed/disabled text |
Accent | Primary accent (brand color) |
AccentAlt | Secondary accent |
Green | Success states |
Red | Error states |
Yellow | Warning states |
Blue | Informational / links |
Magenta | Special highlights |
Cyan | Code / secondary accent |
BorderNormal | Default border color |
BorderFocused | Focused/active border |
Step 1: Define the theme
Add a new file in internal/tui/themes/:
package themes
import "charm.land/lipgloss/v2"
func MyTheme() *Theme {
return &Theme{
Name: "mytheme",
Background: lipgloss.Color("#1a1b26"),
Surface: lipgloss.Color("#24283b"),
Overlay: lipgloss.Color("#414868"),
Text: lipgloss.Color("#c0caf5"),
Subtext: lipgloss.Color("#a9b1d6"),
Muted: lipgloss.Color("#565f89"),
Accent: lipgloss.Color("#7aa2f7"),
AccentAlt: lipgloss.Color("#bb9af7"),
Green: lipgloss.Color("#9ece6a"),
Red: lipgloss.Color("#f7768e"),
Yellow: lipgloss.Color("#e0af68"),
Blue: lipgloss.Color("#7aa2f7"),
Magenta: lipgloss.Color("#bb9af7"),
Cyan: lipgloss.Color("#7dcfff"),
BorderNormal: lipgloss.Color("#414868"),
BorderFocused: lipgloss.Color("#7aa2f7"),
}
}
Step 2: Register in the ThemeEngine
In internal/tui/themes/engine.go, add a RegisterTheme call in NewThemeEngine:
func NewThemeEngine() *ThemeEngine {
te := &ThemeEngine{...}
// Register themes
te.RegisterTheme(Catppuccin())
te.RegisterTheme(Dracula())
te.RegisterTheme(MyTheme()) // ← add here
// ...
}
Built-in Themes
The following themes are available out of the box:
| Name | Style | Background |
|---|---|---|
modern | Default dark theme | #0d1117 |
catppuccin | Catppuccin Mocha | #1e1e2e |
dracula | Dracula | #282a36 |
nord | Nord | #2e3440 |
gruvbox | Gruvbox Dark | #282828 |
onedark | Atom One Dark | #282c34 |
tokyonight | Tokyo Night | #1a1b26 |
solarized-dark | Solarized Dark | #002b36 |
solarized-light | Solarized Light | #fdf6e3 |
high-contrast | Accessibility | #000000 |
monokai | Classic Monokai | #272822 |
Debug Mode
Automergent includes a debug provider that wraps any AI provider to log all requests and responses. This is invaluable for understanding model behavior and debugging prompt issues.
How It Works
When debug mode is enabled, the DebugProvider wraps the primary provider as a transparent decorator:
type DebugProvider struct {
provider ai.Provider
logger *Logger
}
func (d *DebugProvider) Complete(
ctx context.Context, req ai.CompletionRequest,
) (ai.CompletionResponse, error) {
start := time.Now()
d.logger.LogRequest(ctx, d.provider.Name(), req)
resp, err := d.provider.Complete(ctx, req)
if err != nil {
d.logger.LogError(ctx, d.provider.Name(), err, req)
return nil, err
}
d.logger.LogResponse(ctx, d.provider.Name(), resp, time.Since(start))
return resp, nil
}
Enabling Debug Mode
Add to your config or use the CLI flag:
debug:
enabled: true
output_dir: "./debug-logs"
Or via environment variable:
export AUTOMERGENT_DEBUG_ENABLED=true
What Gets Logged
- Requests — full
CompletionRequestincluding messages, tools, and system prompt - Responses — completion response with token usage and latency
- Errors — error details with request context
- Retries — retry attempts via the
RetryObserverinterface
Architecture
The debug wiring in cmd/automergent/main.go shows the decorator chain:
// Provider resolution chain:
provider := googleProvider.New(aiCfg) // base provider
provider = cache.NewCachingProvider(...) // optional caching wrapper
provider = debug.NewDebugProvider(...) // debug wrapper (outermost)
The debug provider also implements Unwrap() to let callers reach the underlying provider's capabilities.
Additional Notes
Session Persistence
Automergent persists conversation sessions to disk using JSONL format. The session system supports:
- Crash recovery via
state.jsonandrecovery.json - Auto-save every 30 seconds
- Session pruning (configurable max age and count)
- Resume with
--resumeor--session <id>
MCP Integration
The Model Context Protocol (MCP) allows extending Automergent with external tool servers. Configure MCP servers in your config:
mcp:
servers:
my-server:
command: "npx"
args: ["-y", "@my/mcp-server"]
env:
API_KEY: "xxx"
Contributing Guidelines
- Fork the repository and create a feature branch
- Write tests for new functionality
- Run
make cibefore submitting - Follow existing code style and patterns
- Keep PRs focused — one feature or fix per PR
- Update documentation if adding user-facing features
Start with a good first issue label, or pick a tool category that interests you. The tool system is the most accessible entry point for new contributors.