n4nAI

Building a Go CLI with Cobra, Viper, and the OpenAI API

Build a Go CLI with Cobra and Viper that calls the OpenAI API. Step-by-step tutorial with runnable code for a practical llm command-line tool.

n4n Team2 min read516 words

Audio narration

Coming soon — every post will get a voice note here.

A solid go cobra viper openai cli gives you a configurable, subcommand-driven tool for hitting LLM endpoints without leaving the terminal. This tutorial builds one from scratch: a small binary that reads API settings from a config file or env vars, then sends prompts to the OpenAI Chat Completions API. By the end you’ll have a working llmcli ask command and the scaffolding to extend it.

Prerequisites

  • Go 1.21 or newer installed and on PATH.
  • An OpenAI API key (or any OpenAI-compatible endpoint credential).
  • Basic comfort with Go modules, go build, and shell environment variables.

If you’ve never used Cobra or Viper, they are the de facto standard for Go command-line tools. Cobra gives you a command tree and flag parsing; Viper handles config files, environment variables, and defaults without hand-rolled boilerplate.

Initialize the Module

Create the project and pull dependencies.

mkdir llmcli && cd llmcli
go mod init github.com/youruser/llmcli
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
go get github.com/sashabaranov/go-openai@latest

The go-openai package is a thin, well-maintained client for the OpenAI REST surface. It covers chat, embeddings, and streaming with minimal overhead.

Root Command and Viper Wiring

Keep main.go tiny and put command logic in a cmd package.

main.go:

package main

import "github.com/youruser/llmcli/cmd"

func main() {
    cmd.Execute()
}

cmd/root.go:

package cmd

import (
    "fmt"
    "os"

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

var rootCmd = &cobra.Command{
    Use:   "llmcli",
    Short: "CLI for interacting with OpenAI models",
}

func Execute() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

func init() {
    viper.SetDefault("model", "gpt-4o-mini")
    viper.SetDefault("baseurl", "https://api.openai.com/v1")
    viper.SetConfigName(".llmcli")
    viper.AddConfigPath("$HOME")
    viper.AddConfigPath(".")
    viper.AutomaticEnv()
    viper.SetEnvPrefix("LLMCLI")
    viper.BindEnv("apikey", "OPENAI_API_KEY")

    if err := viper.ReadInConfig(); err == nil {
        fmt.Fprintln(os.Stderr, "Using config:", viper.ConfigFileUsed())
    }
}

Viper’s AutomaticEnv with SetEnvPrefix means LLMCLI_MODEL overrides the file. Binding apikey to OPENAI_API_KEY keeps secrets out of YAML.

The Ask Subcommand

Create cmd/ask.go. This is where the go cobra viper openai cli actually hits the network.

package cmd

import (
    "context"
    "fmt"
    "strings"

    "github.com/sashabaranov/go-openai"
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var askCmd = &cobra.Command{
    Use:   "ask [prompt]",
    Short: "Send a prompt to OpenAI",
    Args:  cobra.MinimumNArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        apiKey := viper.GetString("apikey")
        if apiKey == "" {
            return fmt.Errorf("apikey not set; export OPENAI_API_KEY")
        }
        model := viper.GetString("model")
        baseURL := viper.GetString("baseurl")

        cfg := openai.DefaultConfig(apiKey)
        if baseURL != "" {
            cfg.BaseURL = baseURL
        }
        client := openai.NewClientWithConfig(cfg)

        prompt := strings.Join(args, " ")

        resp, err := client.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
            Model: model,
            Messages: []openai.ChatCompletionMessage{
                {Role: openai.ChatMessageRoleUser, Content: prompt},
            },
        })
        if err != nil {
            return err
        }
        fmt.Println(resp.Choices[0].Message.Content)
        return nil
    },
}

func init() {
    rootCmd.AddCommand(askCmd)
}

RunE returns an error instead of panicking, so Cobra prints it and exits non-zero. Joining args with spaces lets users type llmcli ask what is 2+2 without shell quotes.

Configuration Precedence

Viper resolves values highest to lowest: flag → env → config file → default. Add a flag override for model:

var modelFlag string

func init() {
    askCmd.Flags().StringVar(&modelFlag, "model", "", "override model")
    rootCmd.AddCommand(askCmd)
}

In RunE:

if modelFlag != "" {
    viper.Set("model", modelFlag)
}

Now ./llmcli ask --model gpt-4o "explain mutexes" works without editing files.

Example Config File

A ~/.llmcli.yaml can hold defaults:

model: gpt-4o-mini
baseurl: https://api.openai.com/v1

Viper merges this with environment. If you export LLMCLI_MODEL=gpt-4o, the env wins. This layering is why a go cobra viper openai cli stays flexible across dev laptops, CI runners, and production containers.

Running It

Build and execute:

go build -o llmcli .
export OPENAI_API_KEY=sk-your-key
./llmcli ask "What is the fastest comparison sort?"

Expected output (truncated):

Using config: /home/user/.llmcli.yaml
Quicksort is generally the fastest comparison-based sorting algorithm in practice, with average O(n log n) time...

Omit the key and you get Error: apikey not set; export OPENAI_API_KEY and exit code 1. On Windows, use set instead of export.

Swapping the Endpoint

The same go cobra viper openai cli code works against any OpenAI-compatible server. Point baseurl at a local llama.cpp instance or a gateway. For example, n4n.ai exposes a single OpenAI-compatible endpoint that fronts 240+ models with automatic fallback when a provider is degraded; set LLMCLI_BASEURL and LLMCLI_APIKEY and the client calls need zero changes.

export LLMCLI_BASEURL=https://api.n4n.ai/v1
export LLMCLI_APIKEY=your-gateway-key
./llmcli ask "summarize distributed consensus"

Building against the OpenAI interface keeps vendor lock-in minimal.

Streaming Responses

For long outputs, blocking on a full response feels sluggish. Use the stream API:

stream, err := client.CreateChatCompletionStream(ctx, openai.ChatCompletionStreamRequest{
    Model: model,
    Messages: []openai.ChatCompletionMessage{
        {Role: openai.ChatMessageRoleUser, Content: prompt},
    },
})
if err != nil {
    return err
}
defer stream.Close()

for {
    resp, err := stream.Recv()
    if err != nil {
        break
    }
    fmt.Print(resp.Choices[0].Delta.Content)
}
fmt.Println()

Wire this behind a --stream flag for a ChatGPT-like typing effect.

Testing the Client Helper

Extract client construction so every command shares it:

func newClient() *openai.Client {
    cfg := openai.DefaultConfig(viper.GetString("apikey"))
    if u := viper.GetString("baseurl"); u != "" {
        cfg.BaseURL = u
    }
    return openai.NewClientWithConfig(cfg)
}

In tests, point baseurl at an httptest.Server that returns canned JSON. This keeps your logic covered without burning API quota.

Packaging and Next Steps

Add rootCmd.Version = "0.1.0" for a free --version flag. Cross-compile with GOOS=linux GOARCH=arm64 go build. For distribution, goreleaser turns this into signed binaries and Homebrew taps with minimal config.

The pattern—Cobra for surface area, Viper for config, a thin OpenAI client for transport—scales to subcommands like chat, embed, or moderate. Keep client creation in a helper so every command inherits the same timeout and retry posture. That’s a production-grade go cobra viper openai cli without the bloat.

Tagsgocobraviperopenai

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All building cli tools for llm apis posts →