Go & Rust CLI Frameworks

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Go 1.27.1, Cobra v1.10.2, Bubble Tea v2, Rust with clap 4.6.7, clap_complete 4.6.11, clap_mangen 0.3.3, Ratatui 0.30.2, Rig 0.42.0 (crates rig and rig-core) and the docsctl running example of this sub-section, as checked on 2026-09-30. Neither the Go nor the Rust toolchain is installed on the machine used to write this page, so the Go and Rust snippets were not compiled; every API name in them was checked against the official documentation instead. Treat them as a sketch to compile before relying on them. The fictional docsctl exists only on these pages, and its behaviour here is illustrative.

Go and Rust produce a single native executable that starts in milliseconds, without a runtime to install, which is exactly what a tool called repeatedly by an agent wants (the reasoning is in Agent Ergonomics). Their parsing libraries derive help and completion from a command tree, so most of the self-description work described in Self-Describing Help comes from the framework. The Rust language itself is covered in the Rust Reference.

Table 1. Go and Rust at a glance
Library Role Main strength for agent-friendly tools

Cobra (Go)

Command tree, flags, help, completion

Generated help, shell completion and reference documentation from the same command definitions.

Bubble Tea (Go)

Terminal user interfaces (the Elm architecture)

Interactive views for people; tea.WithoutRenderer() for non-TTY use.

clap (Rust)

Derive-based parser

Types drive parsing; usage errors exit with 2; companion crates for completions and man pages.

Ratatui (Rust)

Terminal user interfaces

Immediate-mode widgets for dashboards and pickers.

Rig (Rust)

LLM application and agent library

Typed tools and structured output for an agent written in Rust.

Go: Cobra

Cobra models a program as a tree of cobra.Command values. Each has a Use line, a Short and Long description, an Example, an Args validator and a RunE function that returns an error. Execute() parses the command line and runs the right node. Cobra also adds a completion command (bash, zsh, fish, PowerShell) and --help on every command automatically. The two fields that matter most for machine callers are SilenceUsage and SilenceErrors: without them, Cobra prints the full usage text on every runtime error, which is noise in an agent’s context. Setting both and handling the error yourself in main gives control over what is printed and which exit code is returned.

docsctl in Cobra (main.go, abridged; not compiled)
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

const (
	exitUsage                = 2
	exitConfirmationRequired = 5
)

// exitError carries the exit code chosen by a command.
type exitError struct {
	code int
	msg  string
}

func (e *exitError) Error() string { return e.msg }

func newRoot() *cobra.Command {
	var asJSON bool

	root := &cobra.Command{
		Use:           "docsctl",
		Short:         "Search and publish this site's documentation.",
		Version:       "0.1.0",
		SilenceUsage:  true, // no usage dump on runtime errors
		SilenceErrors: true, // main prints errors itself, on stderr
	}
	root.PersistentFlags().BoolVar(&asJSON, "json", false, "Emit JSON on stdout.")

	search := &cobra.Command{
		Use:     "search QUERY",
		Short:   "Search the documentation.",
		Example: "  docsctl search kalman --component irurueta-statistics --json",
		Args:    cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			payload := map[string]any{"schemaVersion": 1, "query": args[0], "total": 0, "results": []any{}}
			if asJSON {
				return json.NewEncoder(cmd.OutOrStdout()).Encode(payload) // data on stdout only
			}
			_, err := fmt.Fprintf(cmd.OutOrStdout(), "0 result(s) for %s\n", args[0])
			return err
		},
	}
	search.Flags().String("component", "", "Restrict to one component.")

	var dryRun, yes bool
	publish := &cobra.Command{
		Use:   "publish",
		Short: "Publish a component (destructive unless --dry-run).",
		RunE: func(cmd *cobra.Command, args []string) error {
			if dryRun {
				_, err := fmt.Fprintln(cmd.OutOrStdout(), `{"dryRun":true,"schemaVersion":1}`)
				return err
			}
			if !yes {
				return &exitError{exitConfirmationRequired,
					"docsctl: publish needs confirmation; re-run with --yes or use --dry-run."}
			}
			return nil
		},
	}
	publish.Flags().String("component", "", "Component to publish.")
	_ = publish.MarkFlagRequired("component")
	publish.Flags().BoolVar(&dryRun, "dry-run", false, "Show the plan only.")
	publish.Flags().BoolVarP(&yes, "yes", "y", false, "Assume yes for confirmations.")

	root.AddCommand(search, publish)
	return root
}

func main() {
	if err := newRoot().Execute(); err != nil {
		var ee *exitError
		if errors.As(err, &ee) {
			fmt.Fprintln(os.Stderr, ee.msg)
			os.Exit(ee.code)
		}
		// Cobra returns flag and argument errors from Execute before any RunE runs;
		// a real tool wraps unexpected RunE errors in an exitError with code 1.
		fmt.Fprintln(os.Stderr, "docsctl:", err)
		os.Exit(exitUsage)
	}
}

The design points: Args: cobra.ExactArgs(1) and MarkFlagRequired push validation into the framework; cmd.OutOrStdout() keeps output testable; and a typed exitError maps failures to the contract’s codes in one place, as the table in Streams & Exit Codes prescribes. Because Cobra returns both parse errors and command errors from Execute, decide explicitly which are usage errors (2) and which are internal (1). Reference: Cobra.

Generating documentation

Cobra’s doc package walks the command tree and writes reference pages, so documentation cannot drift from the code. GenMarkdownTree writes Markdown, GenManTree writes man pages (with a GenManHeader), and there is also GenReSTTree. Cobra’s own guide on documenting CLIs for LLMs recommends setting DisableAutoGenTag for reproducible output, filling in Example fields, and running the generator in CI or at release time.

A small generator in the same module (not compiled)
package main

import (
	"log"

	"github.com/spf13/cobra/doc"
)

func main() {
	root := newRoot()
	root.DisableAutoGenTag = true // stable output for diffs
	if err := doc.GenMarkdownTree(root, "./docs/cli"); err != nil {
		log.Fatal(err)
	}
	header := &doc.GenManHeader{Title: "DOCSCTL", Section: "1"}
	if err := doc.GenManTree(root, header, "./docs/man"); err != nil {
		log.Fatal(err)
	}
}

The Markdown output makes a good input for an agent skill or a documentation site (see Self-Describing Help); the man pages ship with the package. Reference: Cobra — Documenting CLIs for LLMs.

Go: Bubble Tea

Bubble Tea is a framework for terminal user interfaces built on the Elm architecture: a model with Init, Update and View, run with tea.NewProgram(model).Run(). Its current major version is imported as charm.land/bubbletea/v2. Messages such as key presses arrive in Update, which returns the new model, and the framework redraws from View. Programs accept options for redirecting input and output (tea.WithInput, tea.WithOutput) and tea.WithoutRenderer() for non-TTY environments. Those options are what allow a Bubble Tea program to be tested and to be run under an agent, but the cleaner design is not to start the program at all unless a terminal is present and the user asked for it: give docsctl browse its own subcommand and make every function of it available through search, show and --json as well.

docsctl browse — a minimal Bubble Tea v2 model (not compiled)
package main

import (
	"fmt"
	"os"
	"strings"

	tea "charm.land/bubbletea/v2"
)

type result struct{ ID, Title string } // "results" of `docsctl search --json -- kalman`

type model struct {
	results []result
	cursor  int
}

func (m model) Init() tea.Cmd { return nil }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	if key, ok := msg.(tea.KeyPressMsg); ok {
		switch key.String() {
		case "q", "ctrl+c":
			return m, tea.Quit
		case "up", "k":
			m.cursor = max(0, m.cursor-1)
		case "down", "j":
			m.cursor = min(len(m.results)-1, m.cursor+1)
		}
	}
	return m, nil
}

func (m model) View() tea.View {
	var b strings.Builder
	for i, r := range m.results {
		marker := " "
		if i == m.cursor {
			marker = ">"
		}
		fmt.Fprintf(&b, "%s %s  (%s)\n", marker, r.Title, r.ID)
	}
	b.WriteString("\nUp/down to move, q to quit.\n")
	return tea.NewView(b.String())
}

func isTerminal(f *os.File) bool {
	fi, err := f.Stat()
	return err == nil && fi.Mode()&os.ModeCharDevice != 0
}

func browse(results []result) int {
	if !isTerminal(os.Stdin) || !isTerminal(os.Stdout) { // never start a TUI for a pipe or an agent
		fmt.Fprintln(os.Stderr, "docsctl: browse needs a terminal; use `docsctl search --json -- QUERY` instead.")
		return 2
	}
	if _, err := tea.NewProgram(model{results: results}).Run(); err != nil {
		fmt.Fprintln(os.Stderr, "docsctl:", err)
		return 1
	}
	return 0
}

The model follows the shape of the Bubble Tea tutorial for v2: View returns a tea.View built with tea.NewView, and key presses arrive as tea.KeyPressMsg. The terminal test uses only the standard library (os.ModeCharDevice on the result of Stat), and it runs before tea.NewProgram, so a call from a pipe or an agent fails fast with exit code 2 and a pointer to the machine-readable command. Reference: Bubble Tea.

Rust: clap

clap’s derive API turns structs and enums into a parser: [derive(Parser)] on the top-level struct, [derive(Subcommand)] on an enum of commands, [derive(Args)] for a command’s arguments, and [arg(long, global = true)] for a flag available on every subcommand. Cli::parse() prints usage errors to stderr and exits with code 2, which matches the contract; try_parse() returns the error if you want to control it. Doc comments become the help text.

docsctl in clap (main.rs, abridged; not compiled)
use std::io;
use std::process::ExitCode;

use clap::{Args, CommandFactory, Parser, Subcommand};
use clap_complete::Shell;

const CONFIRMATION_REQUIRED: u8 = 5;

/// Search and publish this site's documentation.
#[derive(Parser)]
#[command(name = "docsctl", version)]
struct Cli {
    /// Emit JSON on stdout.
    #[arg(long, global = true)]
    json: bool,

    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Search the documentation.
    Search(SearchArgs),
    /// Publish a component (destructive unless --dry-run).
    Publish(PublishArgs),
    /// Print a shell completion script.
    Completion {
        #[arg(value_enum)]
        shell: Shell,
    },
}

#[derive(Args)]
struct SearchArgs {
    /// Words to look for.
    query: String,
    /// Restrict to one component.
    #[arg(long)]
    component: Option<String>,
}

#[derive(Args)]
struct PublishArgs {
    /// Component to publish.
    #[arg(long)]
    component: String,
    /// Show the plan only.
    #[arg(long)]
    dry_run: bool,
    /// Assume yes for confirmations.
    #[arg(long, short = 'y')]
    yes: bool,
}

fn main() -> ExitCode {
    let cli = Cli::parse(); // usage errors: message on stderr, exit code 2
    match cli.command {
        Command::Search(args) => {
            println!("{{\"query\":\"{}\",\"results\":[],\"schemaVersion\":1,\"total\":0}}", args.query);
            ExitCode::SUCCESS
        }
        Command::Publish(args) if args.dry_run => {
            println!("{{\"dryRun\":true,\"schemaVersion\":1}}");
            ExitCode::SUCCESS
        }
        Command::Publish(args) if !args.yes => {
            eprintln!("docsctl: publish needs confirmation; re-run with --yes or use --dry-run.");
            ExitCode::from(CONFIRMATION_REQUIRED)
        }
        Command::Publish(_) => ExitCode::SUCCESS,
        Command::Completion { shell } => {
            clap_complete::generate(shell, &mut Cli::command(), "docsctl", &mut io::stdout());
            ExitCode::SUCCESS
        }
    }
}

Returning ExitCode from main sets the process status without an explicit process::exit, so destructors still run. A real tool would serialise with serde_json instead of formatting strings. Reference: clap.

Completions and man pages: clap_complete and clap_mangen

clap_complete generates completion scripts from the same Command: its generate function writes a script for a Shell value to any writer, as the completion subcommand above does, and generate_to writes files at build time. clap_mangen renders ROFF man pages: clap_mangen::Man::new(command) followed by render(&mut buffer), ideally in a build.rs or a release task; its documentation recommends generating man pages during development rather than through a flag on the shipped program.

Rendering a man page (not compiled)
use clap::CommandFactory;

fn main() -> std::io::Result<()> {
    let man = clap_mangen::Man::new(Cli::command());
    let mut buffer: Vec<u8> = Vec::new();
    man.render(&mut buffer)?;
    std::fs::write("docsctl.1", buffer)
}

References: clap_complete and clap_mangen.

Rust: Ratatui

Ratatui is an immediate-mode library for terminal interfaces: widgets such as Paragraph, tables, gauges and lists are drawn each frame into layout areas computed with constraints. The crate root offers ratatui::run, which initialises and restores the terminal around a closure, so a minimal program is only a few lines:

The Ratatui quickstart (from the crate documentation)
use crossterm::event;

fn main() -> std::io::Result<()> {
    ratatui::run(|mut terminal| {
        loop {
            terminal.draw(|frame| frame.render_widget("Hello World!", frame.area()))?;
            if event::read()?.is_key_press() {
                break Ok(());
            }
        }
    })
}

For docsctl, such a view would show search results with a preview pane, behind a docsctl browse subcommand. The same rule as for Bubble Tea and Ink applies: the TUI is an addition, and search --json remains the interface for agents and scripts. The note on terminal state is important: a TUI that panics without restoring the terminal leaves the user’s shell unusable, and ratatui::run exists to handle that restoration. Reference: Ratatui.

Rust: Rig

Rig is a Rust library for LLM applications and agents, with one API across many model providers, type-safe tools and structured output; its site and repository also list MCP support, which the README notes as native-only (not available for WebAssembly targets). It belongs to the other side of this sub-section’s story: a docsctl binary is something an agent calls, and Rig is how you would write the agent that calls it, or an agent that exposes Rust functions as typed tools. Two practical points:

  • Two crates are published at 0.42.0: rig-core (imported as rig_core) and rig, a facade that re-exports it at rig::…​ paths and is what the project site (cargo add rig) and the repository’s examples use; the sketch below follows those examples. Confirm the current package name and import path in the repository before you add the dependency, and check the examples directory for the tool and MCP APIs of the release you use, because they are still changing.

  • An agent library is not a substitute for the CLI’s own safety rules. Tools defined in Rig should run docsctl with an argument array, an allowlist of subcommands and --no-input, exactly as described on Wrapping a CLI as an Agent Tool and Security.

The sketch below gives an agent one typed tool, docsctl_search. It follows the Tool trait as used in the repository’s examples at the v0.42.0 tag (const NAME, typed Args, Output and Error, description, parameters and an async call that receives a ToolContext) and the agent builder from the same examples.

An agent with one typed docsctl tool (Rig 0.42.0; not compiled)
use std::process::Command;

use rig::completion::Prompt;
use rig::prelude::*;
use rig::providers::anthropic;
use rig::tool::{Tool, ToolContext};
use serde::Deserialize;
use serde_json::json;

const DOCSCTL: &str = "/usr/local/bin/docsctl"; // absolute path, not a PATH lookup

#[derive(Deserialize)]
struct SearchArgs {
    query: String,
}

#[derive(Debug, thiserror::Error)]
enum DocsctlError {
    #[error("query must be 1-200 characters")]
    InvalidQuery,
    #[error("could not run docsctl: {0}")]
    Io(#[from] std::io::Error),
    #[error("docsctl exited with {code:?}: {stderr}")]
    Failed { code: Option<i32>, stderr: String },
}

struct DocsctlSearch;

impl Tool for DocsctlSearch {
    const NAME: &'static str = "docsctl_search";
    type Args = SearchArgs;
    type Output = String; // the JSON document printed by docsctl
    type Error = DocsctlError;

    fn description(&self) -> String {
        "Search this site's documentation and return docsctl's JSON result.".to_string()
    }

    fn parameters(&self) -> serde_json::Value {
        json!({
            "type": "object",
            "properties": { "query": { "type": "string", "minLength": 1, "maxLength": 200 } },
            "required": ["query"]
        })
    }

    async fn call(&self, _context: &mut ToolContext, args: Self::Args) -> Result<Self::Output, Self::Error> {
        if args.query.trim().is_empty() || args.query.len() > 200 {
            return Err(DocsctlError::InvalidQuery);
        }
        let output = Command::new(DOCSCTL)                         // an argv array, never a shell
            .args(["search", "--json", "--no-input", "--"])        // "--" before the user operand
            .arg(&args.query)
            .env_clear()
            .env("PATH", "/usr/bin:/bin")
            .output()?;
        if !output.status.success() {
            return Err(DocsctlError::Failed {
                code: output.status.code(),
                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
            });
        }
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let agent = anthropic::Client::from_env()?                     // reads the API key from the environment
        .agent(anthropic::completion::CLAUDE_SONNET_4_6)
        .preamble("Answer questions about this site's documentation. Call docsctl_search before answering.")
        .tool(DocsctlSearch)
        .default_max_turns(4)
        .build();
    let answer = agent.prompt("What does the Kalman filter page cover?").await?;
    println!("{answer}");
    Ok(())
}

It needs rig, tokio, serde, serde_json, thiserror and anyhow in Cargo.toml. The validation in call repeats the schema’s limits because the model’s arguments are untrusted. Command::output blocks the thread and reads the whole output with no time limit; in a real agent, run the child with tokio::process::Command or on a blocking thread, and add the timeout and output cap described on Wrapping a CLI as an MCP Server. Reference: Rig and Rig on GitHub.

Choosing between Go and Rust

Both give a fast native binary and a single artifact to distribute (see Packaging & Distribution for GoReleaser and cargo install). Choose the one your team already maintains. Cobra is the shortest path to a conventional multi-command tool with generated docs and completion. clap gives stronger compile-time checking of the command line and the best fit when the tool shares code with Rust services or a Ratatui interface.