Go é a linguagem de fato para ferramentas de linha de comando. Docker, Kubernetes, Terraform, GitHub CLI, Hugo e centenas de outras ferramentas amplamente usadas foram escritas em Go. As razões são concretas: compilação para binário estático sem dependências, distribuição trivial, startup instantâneo, cross-compilation nativa e performance próxima ao C.
Este artigo cobre duas abordagens: o pacote flag da biblioteca padrão para ferramentas simples, e o Cobra para CLIs com subcomandos, flags complexas e help automático — o padrão da indústria para ferramentas profissionais.
O pacote flag: para CLIs simples
package main
import (
"flag"
"fmt"
"os"
"time"
)
func main() {
// Definindo flags
host := flag.String("host", "localhost", "endereço do servidor")
porta := flag.Int("port", 8080, "porta do servidor")
verbose := flag.Bool("verbose", false, "ativar saída detalhada")
timeout := flag.Duration("timeout", 30*time.Second, "timeout das requisições")
// Personalizar o texto de uso
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Uso: %s [opções]\n\n", os.Args[0])
fmt.Fprintln(os.Stderr, "Opções:")
flag.PrintDefaults()
}
flag.Parse()
// Argumentos posicionais (após as flags)
args := flag.Args()
if *verbose {
fmt.Printf("Conectando em %s:%d (timeout: %v)\n", *host, *porta, *timeout)
fmt.Printf("Argumentos extras: %v\n", args)
}
fmt.Printf("Servidor: %s:%d\n", *host, *porta)
}
# Uso
./app -host=api.exemplo.com -port=443 -verbose arquivo.txt
./app --host api.exemplo.com --port 443
./app -help
O flag aceita um ou dois hífens e o valor com = ou separado por espaço, mas para de ler flags no primeiro argumento que não é flag. Em ./app arquivo.txt -verbose, o -verbose vira mais um argumento posicional, e o programa roda sem a saída detalhada e sem erro nenhum.
O flag funciona bem para ferramentas com um único conjunto de opções. Quando a CLI cresce e precisa de subcomandos como git commit, git push, docker run, o Cobra é a escolha certa.
Cobra: o framework profissional para CLIs
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest # para configuração
A estrutura de um projeto CLI com Cobra:
minha-cli/
├── go.mod
├── main.go
├── cmd/
│ ├── root.go ← comando raiz
│ ├── serve.go ← subcomando: minha-cli serve
│ ├── migrate.go ← subcomando: minha-cli migrate
│ └── usuario/
│ ├── usuario.go ← grupo: minha-cli usuario
│ ├── criar.go ← minha-cli usuario criar
│ └── listar.go ← minha-cli usuario listar
└── internal/
└── config/
└── config.go
Comando raiz
// cmd/root.go
package cmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"minha-cli/cmd/usuario"
)
var (
cfgFile string
verbose bool
formato string
)
var rootCmd = &cobra.Command{
Use: "minha-cli",
Short: "Ferramenta de gerenciamento da aplicação",
Long: `minha-cli é uma ferramenta completa para gerenciar
a aplicação, incluindo servidor, banco de dados e usuários.
Documentação completa em: https://docs.exemplo.com`,
// Erro de execução não é erro de uso: sem isto, o Cobra imprime o
// texto de ajuda inteiro junto de cada erro devolvido por um RunE
SilenceUsage: true,
// PersistentPreRunE executa antes de QUALQUER subcomando
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return inicializarConfig()
},
}
func Execute() {
// O Cobra já imprime o erro ("Error: ..."); imprimir de novo aqui o duplicaria
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(inicializarViper)
// Flags persistentes — disponíveis em todos os subcomandos
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"arquivo de configuração (padrão: $HOME/.minha-cli.yaml)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v",
false, "saída detalhada")
rootCmd.PersistentFlags().StringVar(&formato, "format", "text",
"formato de saída: text, json, yaml")
// Sem o vínculo, viper.GetString("format") nunca enxerga o --format
viper.BindPFlag("format", rootCmd.PersistentFlags().Lookup("format"))
// O grupo é registrado aqui: o pacote usuario não importa cmd, e o
// import acima é o que garante que ele entre no binário
rootCmd.AddCommand(usuario.UsuarioCmd)
}
func inicializarViper() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigType("yaml")
viper.SetConfigName(".minha-cli")
}
// Variáveis de ambiente sobrescrevem a config. O replacer faz a chave
// server.port corresponder a SERVER_PORT, que um shell consegue definir
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil && verbose {
fmt.Fprintln(os.Stderr, "Configuração:", viper.ConfigFileUsed())
}
}
func inicializarConfig() error {
if verbose {
fmt.Printf("Usando banco: %s\n", viper.GetString("database.url"))
}
return nil
}
Subcomando: serve
// cmd/serve.go
package cmd
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
serveHost string
servePort int
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Inicia o servidor HTTP",
Long: `Inicia o servidor HTTP da aplicação na porta especificada.`,
Example: ` minha-cli serve
minha-cli serve --port 9090
minha-cli serve --host 0.0.0.0 --port 443`,
RunE: func(cmd *cobra.Command, args []string) error {
// Lê pelo viper, e não pelas variáveis das flags: só assim
// a config e o ambiente valem quando a flag não é passada
return executarServidor(viper.GetString("server.host"), viper.GetInt("server.port"))
},
}
func init() {
rootCmd.AddCommand(serveCmd)
serveCmd.Flags().StringVar(&serveHost, "host", "0.0.0.0",
"endereço de escuta")
serveCmd.Flags().IntVarP(&servePort, "port", "p", 8080,
"porta de escuta")
// Vincula flags com viper — permite sobrescrever via config ou env
viper.BindPFlag("server.port", serveCmd.Flags().Lookup("port"))
viper.BindPFlag("server.host", serveCmd.Flags().Lookup("host"))
}
func executarServidor(host string, porta int) error {
addr := fmt.Sprintf("%s:%d", host, porta)
mux := http.NewServeMux()
mux.HandleFunc("/saude", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
servidor := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
fmt.Printf("Servidor iniciado em http://%s\n", addr)
if err := servidor.ListenAndServe(); err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "Erro: %v\n", err)
os.Exit(1)
}
}()
<-quit
fmt.Println("\nEncerrando servidor...")
ctx, cancelar := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelar()
return servidor.Shutdown(ctx)
}
Grupo de subcomandos: usuario
// cmd/usuario/usuario.go
package usuario
import (
"github.com/spf13/cobra"
)
// UsuarioCmd é exportado para o cmd/root.go registrá-lo. Este pacote
// não importa cmd: se importasse, e o root importasse este, seria um ciclo
var UsuarioCmd = &cobra.Command{
Use: "usuario",
Short: "Gerencia usuários da aplicação",
Long: `Comandos para criar e listar usuários.`,
}
func init() {
UsuarioCmd.AddCommand(criarCmd)
UsuarioCmd.AddCommand(listarCmd)
}
// cmd/usuario/criar.go
package usuario
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
var (
criarNome string
criarEmail string
criarPapel string
)
var criarCmd = &cobra.Command{
Use: "criar",
Short: "Cria um novo usuário",
Example: ` minha-cli usuario criar --nome "João Silva" --email joao@exemplo.com
minha-cli usuario criar --nome "Admin" --email admin@exemplo.com --papel admin`,
RunE: func(cmd *cobra.Command, args []string) error {
return executarCriarUsuario(criarNome, criarEmail, criarPapel)
},
}
func init() {
criarCmd.Flags().StringVarP(&criarNome, "nome", "n", "", "nome completo (obrigatório)")
criarCmd.Flags().StringVarP(&criarEmail, "email", "e", "", "email (obrigatório)")
criarCmd.Flags().StringVar(&criarPapel, "papel", "usuario",
"papel do usuário: usuario, admin, moderador")
// Marca flags como obrigatórias
criarCmd.MarkFlagRequired("nome")
criarCmd.MarkFlagRequired("email")
}
func executarCriarUsuario(nome, email, papel string) error {
papeis := map[string]bool{"usuario": true, "admin": true, "moderador": true}
if !papeis[strings.ToLower(papel)] {
return fmt.Errorf("papel inválido: %s (aceitos: usuario, admin, moderador)", papel)
}
fmt.Printf("Criando usuário...\n")
fmt.Printf(" Nome: %s\n", nome)
fmt.Printf(" Email: %s\n", email)
fmt.Printf(" Papel: %s\n", papel)
// Aqui viria a lógica real de criação
fmt.Println("✓ Usuário criado com sucesso")
return nil
}
// cmd/usuario/listar.go
package usuario
import (
"encoding/json"
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
listarLimite int
listarPapel string
)
var listarCmd = &cobra.Command{
Use: "listar",
Short: "Lista usuários cadastrados",
RunE: func(cmd *cobra.Command, args []string) error {
return executarListarUsuarios(listarLimite, listarPapel)
},
}
func init() {
listarCmd.Flags().IntVar(&listarLimite, "limite", 20, "máximo de registros")
listarCmd.Flags().StringVar(&listarPapel, "papel", "", "filtrar por papel")
}
type UsuarioListado struct {
ID int `json:"id"`
Nome string `json:"nome"`
Email string `json:"email"`
Papel string `json:"papel"`
Ativo bool `json:"ativo"`
}
func executarListarUsuarios(limite int, papel string) error {
// Simulação — em produção buscaria do banco
usuarios := []UsuarioListado{
{1, "Ricardo Matos", "ricardo@exemplo.com", "admin", true},
{2, "Ana Silva", "ana@exemplo.com", "usuario", true},
{3, "Carlos Lima", "carlos@exemplo.com", "moderador", false},
}
if papel != "" {
filtrados := usuarios[:0]
for _, u := range usuarios {
if u.Papel == papel {
filtrados = append(filtrados, u)
}
}
usuarios = filtrados
}
if len(usuarios) > limite {
usuarios = usuarios[:limite]
}
// Formato de saída configurável
formato := viper.GetString("format")
switch formato {
case "json":
return json.NewEncoder(os.Stdout).Encode(usuarios)
default:
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNOME\tEMAIL\tPAPEL\tATIVO")
fmt.Fprintln(w, "--\t----\t-----\t-----\t-----")
for _, u := range usuarios {
ativo := "sim"
if !u.Ativo {
ativo = "não"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n",
u.ID, u.Nome, u.Email, u.Papel, ativo)
}
return w.Flush()
}
}
Subcomando: migrate
// cmd/migrate.go
package cmd
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
var migrateCmd = &cobra.Command{
Use: "migrate",
Short: "Gerencia migrações do banco de dados",
}
var migrateUpCmd = &cobra.Command{
Use: "up",
Short: "Executa todas as migrações pendentes",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Executando migrações...")
// golang-migrate aqui
fmt.Println("✓ Migrações concluídas")
return nil
},
}
var migrateDownCmd = &cobra.Command{
Use: "down [n]",
Short: "Reverte a última migração (ou N migrações)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
n := 1
if len(args) > 0 {
var err error
n, err = strconv.Atoi(args[0])
if err != nil || n < 1 {
return fmt.Errorf("n deve ser um inteiro positivo, recebido %q", args[0])
}
}
fmt.Printf("Revertendo %d migração(ões)...\n", n)
fmt.Println("✓ Concluído")
return nil
},
}
var migrateStatusCmd = &cobra.Command{
Use: "status",
Short: "Exibe o status das migrações",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Versão atual: 5")
fmt.Println("Pendentes: 0")
return nil
},
}
func init() {
rootCmd.AddCommand(migrateCmd)
migrateCmd.AddCommand(migrateUpCmd)
migrateCmd.AddCommand(migrateDownCmd)
migrateCmd.AddCommand(migrateStatusCmd)
}
main.go: ponto de entrada enxuto
// main.go
package main
import "minha-cli/cmd"
func main() {
cmd.Execute()
}
Completions: autocompletar no shell
O Cobra acrescenta sozinho um subcomando completion, com scripts para bash, zsh, fish e PowerShell: ele aparece no --help de qualquer CLI feita com o Cobra, sem nenhuma linha de código. Escrever o comando à mão só é necessário para mudar o seu comportamento; um comando próprio com o nome completion substitui o padrão:
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Gera script de autocompletar para o shell",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return rootCmd.GenBashCompletionV2(os.Stdout, true)
case "zsh":
return rootCmd.GenZshCompletion(os.Stdout)
case "fish":
return rootCmd.GenFishCompletion(os.Stdout, true)
case "powershell":
return rootCmd.GenPowerShellCompletion(os.Stdout)
default:
return fmt.Errorf("shell não suportado: %s", args[0])
}
},
}
# Instalando o autocompletar no bash
minha-cli completion bash > /etc/bash_completion.d/minha-cli
# Ou temporariamente na sessão atual
source <(minha-cli completion bash)
Saída colorida e progress
import "github.com/fatih/color"
var (
verde = color.New(color.FgGreen).SprintFunc()
vermelho = color.New(color.FgRed).SprintFunc()
amarelo = color.New(color.FgYellow).SprintFunc()
azul = color.New(color.FgCyan).SprintFunc()
)
func imprimirStatus(ok bool, mensagem string) {
if ok {
fmt.Printf("%s %s\n", verde("✓"), mensagem)
} else {
fmt.Printf("%s %s\n", vermelho("✗"), mensagem)
}
}
func imprimirAviso(mensagem string) {
fmt.Printf("%s %s\n", amarelo("⚠"), mensagem)
}
func imprimirInfo(mensagem string) {
fmt.Printf("%s %s\n", azul("ℹ"), mensagem)
}
Compilando e distribuindo
# Build padrão
go build -o minha-cli .
# Build com versão embutida. O -X precisa do caminho completo do pacote
# onde a variável está; um nome que não existe é ignorado sem aviso
go build -ldflags="-X minha-cli/cmd.versao=1.2.0 -X minha-cli/cmd.build=$(date -u +%Y%m%d)" -o minha-cli .
# Cross-compilation para múltiplos sistemas
GOOS=linux GOARCH=amd64 go build -o dist/minha-cli-linux-amd64 .
GOOS=darwin GOARCH=arm64 go build -o dist/minha-cli-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -o dist/minha-cli-windows-amd64.exe .
// cmd/version.go — versão embutida via ldflags
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var (
versao = "desenvolvimento"
build = "desconhecido"
)
var versaoCmd = &cobra.Command{
Use: "version",
Short: "Exibe a versão da ferramenta",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("minha-cli v%s (build: %s)\n", versao, build)
},
}
func init() {
rootCmd.AddCommand(versaoCmd)
}
O flag resolve ferramentas de um comando só, e o Cobra organiza as maiores em subcomandos, com ajuda, validação de argumentos e autocompletar prontos. Quase todo o trabalho que sobra está nas ligações entre as peças: o pacote de um grupo de comandos só entra no binário se alguém o importar, uma flag só chega ao Viper se for vinculada, e uma configuração só vale se o comando ler pelo Viper, e não pela variável da flag. Nenhuma dessas falhas impede a compilação, e por isso vale testar a CLI pronta pelo terminal, com --help, flags, variáveis de ambiente e arquivo de configuração.
Fontes e leituras recomendadas
-
Documentação do Cobra — Guia oficial com todos os recursos. https://github.com/spf13/cobra
-
Cobra Generator — Ferramenta para gerar estrutura de projeto Cobra. https://github.com/spf13/cobra-cli
-
Viper — Biblioteca de configuração que integra com Cobra. https://github.com/spf13/viper
-
fatih/color — Saída colorida para terminais. https://github.com/fatih/color
-
olekukonko/tablewriter — Tabelas formatadas para saída de CLIs. https://github.com/olekukonko/tablewriter
-
Documentação flag — biblioteca padrão — Referência do pacote flag. https://pkg.go.dev/flag
Exercícios
Exercício 1
Na primeira versão deste projeto, o pacote cmd/usuario se registrava no próprio init, com cmd.RootCmd.AddCommand(UsuarioCmd), e o main.go importava só minha-cli/cmd. O go build passava, mas minha-cli usuario listar respondia unknown command "usuario". Por que o build não acusou nada, e qual é a forma certa de ligar os pacotes?
Ver resposta
✓ Resposta: O Go compila apenas os pacotes alcançáveis a partir do main pelos imports. Como ninguém importava cmd/usuario, o pacote ficava fora do build: o init dele nunca rodava, e nem os seus erros de compilação eram vistos. Num teste, go build . terminou sem erro e o --help listou completion, migrate e serve, sem usuario; só o go build ./... mostrou que o pacote nem compilava, com undefined: cmd.RootCmd, porque a variável era rootCmd, minúscula. Fazer o cmd importar usuario para resolver criaria um ciclo, já que usuario importava cmd, e o Go recusa ciclos de import. A forma certa é a dependência ir num sentido só: usuario exporta o comando e não sabe nada da raiz, e o cmd/root.go importa usuario e chama rootCmd.AddCommand(usuario.UsuarioCmd). Rodar go build ./... e go vet ./... no CI pega pacotes esquecidos.
Exercício 2
O serve vincula as flags com viper.BindPFlag("server.port", ...), e o arquivo de configuração diz server: port: 9999. Na versão antiga, o servidor subia sempre na 8080, com ou sem o arquivo, e SERVER_PORT=9998 também não mudava nada. Quais eram os dois defeitos?
Ver resposta
✓ Resposta: O BindPFlag faz o Viper consultar a flag ao responder viper.GetInt("server.port"), numa ordem de precedência: flag passada, variável de ambiente, arquivo de configuração e, por fim, o valor padrão da flag. Mas a versão antiga nunca perguntava ao Viper: o RunE passava a variável servePort, preenchida só pela flag, e o arquivo e o ambiente eram ignorados. Num teste, com o arquivo e com a variável, o servidor subiu em 0.0.0.0:8080. O segundo defeito estava no nome da variável de ambiente: o AutomaticEnv transforma a chave server.port em SERVER.PORT, com ponto, um nome que os shells não deixam definir. Com o RunE lendo pelo Viper e o SetEnvKeyReplacer(strings.NewReplacer(".", "_")), a mesma CLI subiu na 9999 com o arquivo, na 9998 com SERVER_PORT, e na 9997 com -p 9997 além da variável, porque a flag passada vence.
Exercício 3
O pipeline de release compila com -ldflags="-X main.versao=1.2.0", e minha-cli version continua respondendo vdesenvolvimento. O build não mostra aviso nenhum. O que está errado?
Ver resposta
✓ Resposta: O -X do linker recebe o caminho completo do pacote mais o nome da variável, e a variável versao está no pacote minha-cli/cmd, junto do comando version, e não no main. Quando o símbolo indicado não existe, o linker simplesmente não faz nada: num teste, o mesmo build com -X minha-cli/cmd.versao=1.2.0 -X main.versao=x terminou sem aviso, e o binário respondeu minha-cli v1.2.0, ignorando a parte errada. O -X também só funciona com variáveis do tipo string que não sejam constantes nem inicializadas por chamada de função. Para não depender disso, o Go grava sozinho no binário a versão do módulo e os dados do controle de versão, legíveis com debug.ReadBuildInfo() ou com go version -m minha-cli; o -X fica para o que não está lá, como o número de uma release.
Exercício 4
Um usuário digita minha-cli migrate down tres numa versão antiga do comando, que lia o argumento com fmt.Sscan(args[0], &n). O que acontece com o banco, e por que esse tipo de defeito é mais grave numa CLI?
Ver resposta
✓ Resposta: O fmt.Sscan devolve um erro quando o texto não é um número, mas o código antigo descartava o retorno, e o n ficava com o valor inicial, 1. Num teste, down abc imprimiu Revertendo 1 migração(ões)... e terminou com sucesso: o usuário pediu algo impossível e o comando reverteu uma migração mesmo assim. Com o strconv.Atoi e o erro devolvido pelo RunE, o comando parou com Error: n deve ser um inteiro positivo, recebido "abc" e código de saída 1, sem tocar o banco. Numa CLI, o defeito pesa mais porque o comando costuma rodar dentro de scripts e pipelines, que só olham o código de saída: um sucesso falso deixa o script seguir em frente. Comandos destrutivos ainda merecem uma camada a mais, como pedir confirmação ou exigir uma flag --force quando não há terminal interativo.
Exercício 5
Um script chama ./app relatorio.csv -verbose, usando o programa da primeira seção, feito com o pacote flag. Não aparece a saída detalhada, e não há erro. Com ./app -verbose relatorio.csv, funciona. Por quê, e o que muda com o Cobra?
Ver resposta
✓ Resposta: O flag.Parse lê flags até encontrar o primeiro argumento que não começa com hífen e para ali: tudo o que vem depois, inclusive o -verbose, vai para flag.Args() como argumento posicional. Num teste, go run . arquivo.txt -verbose -port 9 imprimiu Servidor: localhost:8080, ignorando as duas flags, sem erro. É o comportamento tradicional das ferramentas Unix e permite passar argumentos que começam com hífen, mas surpreende quem está acostumado a outros programas. O Cobra usa o pacote pflag, que aceita flags misturadas aos argumentos em qualquer posição e só para de interpretá-las depois de um -- isolado. Com o flag padrão, a saída é documentar a ordem no texto de uso ou verificar flag.Args() e recusar argumentos que pareçam flags.