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.