Se a pergunta é como criar uma CLI profissional em Go em 2026, a resposta mais citada por times brasileiros de plataforma, SRE e backend é Cobra: árvore de comandos clara, help automático, flags persistentes, completions e um contrato de saída que scripts e CI conseguem confiar. O pacote flag da biblioteca padrão resolve o protótipo de um comando só. Quando a ferramenta ganha serve, migrate, version, --json e configuração por ambiente, Cobra deixa de ser “mais uma dependência” e vira o esqueleto do produto.
Este guia é o deep dive de produção em cima de Cobra: contrato de exit codes, stdout versus stderr, saída JSON estável, context com cancelamento, integração com Viper, testes sem flakiness e o caminho até GoReleaser. Para o passo a passo introdutório (flag nativo, primeiro root.go, exemplo de backup), use o tutorial de CLI tools em Go. Se a interface for interativa no terminal, compare com Bubble Tea — Cobra e TUI resolvem problemas diferentes.
Resposta rápida: Cobra ou flag?
| Cenário | Escolha |
|---|---|
| Um binário, 2–3 flags, sem subcomando | flag da stdlib |
Subcomandos (serve, migrate, completion) | Cobra |
Flags globais (--config, --verbose) + locais | Cobra (persistent + local) |
| Completions bash/zsh e help gerado | Cobra |
| UI interativa (listas, spinners, painéis) | Bubble Tea (Cobra pode só lançar o modo TUI) |
| Distribuir binário versionado multi-OS | Cobra + GoReleaser |
Regra prática: se o README já lista mais de um verbo na linha de comando, comece com Cobra. Refatorar de flag para Cobra no meio do caminho dói mais do que adotar cedo.
O modelo mental do Cobra
Cobra modela a CLI como uma árvore de *cobra.Command:
- cada nó tem
Use,Short,Long,Run/RunE; - flags locais valem só naquele comando;
- flags persistentes descem para os filhos;
Execute()parseiaos.Args, escolhe o nó e roda o handler.
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func main() {
root := &cobra.Command{
Use: "jobctl",
Short: "Controla jobs de processamento em lote",
}
var verbose bool
root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "log detalhado em stderr")
versionCmd := &cobra.Command{
Use: "version",
Short: "Imprime a versão do binário",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.OutOrStdout(), "jobctl 1.4.2")
return nil
},
}
root.AddCommand(versionCmd)
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
Três detalhes que separam CLI de brinquedo de CLI de produção:
RunEem vez deRun— erros sobem para oExecutee você controla o exit code.cmd.OutOrStdout()/cmd.ErrOrStderr()— permitem redirecionar saída em testes sem tocar emos.Stdoutglobal.- Lógica de negócio fora do
RunE— o comando só traduz flags em parâmetros; a função de domínio recebecontext.Contexte interfaces.
Estrutura de projeto que escala
Para ferramentas que vão viver anos (CLIs internas de fintech, operadores de plataforma, geradores de código), evite colocar tudo em main.go:
cmd/
jobctl/
main.go # só monta root e chama Execute
internal/
cli/
root.go # NewRoot() *cobra.Command
serve.go
migrate.go
version.go
app/
server.go # lógica real
migrator.go
main fica fino:
package main
import (
"os"
"example.com/jobctl/internal/cli"
)
func main() {
if err := cli.NewRoot().Execute(); err != nil {
os.Exit(1)
}
}
Essa separação combina com Clean Architecture sem overengineering e com dependency injection sem framework: o RunE resolve dependências (logger, config, cliente HTTP) e chama o caso de uso.
Subcomandos e flags persistentes
Flags persistentes no root (--config, --verbose, --output) evitam repetir a mesma opção em cada filho. Flags locais ficam no comando que as consome (--port em serve, --dry-run em migrate).
func NewRoot() *cobra.Command {
var (
cfgPath string
output string
)
root := &cobra.Command{
Use: "jobctl",
Short: "Controla jobs de processamento em lote",
SilenceUsage: true, // não despeja help em erro de runtime
SilenceErrors: true, // você imprime o erro do seu jeito
}
root.PersistentFlags().StringVar(&cfgPath, "config", "", "arquivo de configuração")
root.PersistentFlags().StringVarP(&output, "output", "o", "text", "text|json")
root.AddCommand(newServeCmd(&cfgPath, &output))
root.AddCommand(newMigrateCmd(&cfgPath, &output))
root.AddCommand(newVersionCmd())
return root
}
func newServeCmd(cfgPath, output *string) *cobra.Command {
var port int
cmd := &cobra.Command{
Use: "serve",
Short: "Sobe o servidor HTTP de jobs",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
cfg, err := loadConfig(*cfgPath)
if err != nil {
return err
}
if port != 0 {
cfg.Port = port
}
return app.RunServer(ctx, cfg, *output)
},
}
cmd.Flags().IntVar(&port, "port", 0, "porta HTTP (sobrescreve config)")
return cmd
}
SilenceUsage: true é o detalhe que a maioria esquece: em produção, um erro de banco não deve imprimir duas páginas de help. Help é para uso incorreto; erro de runtime vai para stderr com mensagem curta.
Contrato de saída: stdout, stderr e exit codes
Scripts, pipelines e GitHub Actions não leem a sua intenção — leem bytes e código de saída.
| Stream | Conteúdo |
|---|---|
| stdout | Resultado consumível: JSON, tabela, ID criado, versão |
| stderr | Log humano, progresso, avisos, stack resumida |
| exit 0 | Sucesso |
| exit 2 | Uso incorreto (flag inválida, args faltando) |
| exit 1 | Falha de execução (rede, banco, regra de negócio) |
func writeResult(cmd *cobra.Command, output string, v any) error {
switch output {
case "json":
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(v)
case "text":
_, err := fmt.Fprintln(cmd.OutOrStdout(), v)
return err
default:
return fmt.Errorf("output %q inválido (use text|json)", output)
}
}
Nunca misture log estruturado em stdout se a saída for pipeada para jq. Use slog apontando para stderr, ou um nível debug só com --verbose.
Para mapear erros de validação de flag para exit 2, trate a função de saída:
func Execute() int {
root := NewRoot()
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "erro:", err)
if errors.Is(err, errUsage) {
return 2
}
return 1
}
return 0
}
func main() { os.Exit(Execute()) }
context: Ctrl+C, timeout e cancelamento
CLIs longas (migrate, export, serve) precisam reagir a SIGINT/SIGTERM. O Cobra moderno propaga cmd.Context(); combine com graceful shutdown e context com timeout:
root.PersistentPreRun = func(cmd *cobra.Command, args []string) {
// cmd.Context() já cancela em sinais quando configurado via CommandContext
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
root := cli.NewRoot()
if err := root.ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Dentro do RunE, passe cmd.Context() para HTTP clients, migrations e workers. Assim um Ctrl+C cancela a operação em andamento em vez de deixar goroutine órfã — o mesmo espírito de errgroup e cancelamento.
Cobra + Viper: uma fonte de verdade
O casamento clássico do ecossistema:
- Cobra declara a flag;
- Viper bind a flag e o env;
- A aplicação lê só do Viper (ou de uma struct preenchida uma vez no boot).
func bindConfig(cmd *cobra.Command) error {
v := viper.New()
v.SetEnvPrefix("JOBCTL")
v.AutomaticEnv()
if cfg := cmd.Flags().Lookup("config"); cfg != nil && cfg.Value.String() != "" {
v.SetConfigFile(cfg.Value.String())
if err := v.ReadInConfig(); err != nil {
return err
}
}
if err := v.BindPFlag("port", cmd.Flags().Lookup("port")); err != nil {
return err
}
return nil
}
Precedência que o time precisa documentar no README (da mais forte para a mais fraca):
- flag na linha de comando;
- variável de ambiente;
- arquivo de configuração;
- default no código.
Detalhes e armadilhas de secrets estão no guia de configuração com Viper.
Completions e help que vendem a ferramenta
Completions não são “nice to have”: em times grandes, a CLI que completa subcomandos e valores de --output é a que as pessoas realmente usam.
root.AddCommand(&cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Gera script de autocomplete",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(cmd.OutOrStdout())
case "zsh":
return cmd.Root().GenZshCompletion(cmd.OutOrStdout())
case "fish":
return cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(cmd.OutOrStdout())
default:
return fmt.Errorf("shell %q não suportado", args[0])
}
},
})
Documente no README:
# zsh
jobctl completion zsh > "${fpath[1]}/_jobctl"
Short e Long bem escritos são documentação barata: o help gerado vira a referência diária do time. Mantenha exemplos reais em Example:
Example: ` jobctl serve --port 8080
jobctl migrate --dry-run
jobctl version --output json`,
Testes: ExecuteC, buffers e lógica extraída
Não suba servidor de verdade no teste do parser. O padrão idiomático:
func TestVersionJSON(t *testing.T) {
root := cli.NewRoot()
buf := new(bytes.Buffer)
errBuf := new(bytes.Buffer)
root.SetOut(buf)
root.SetErr(errBuf)
root.SetArgs([]string{"version", "--output", "json"})
_, err := root.ExecuteC()
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(buf.Bytes(), []byte(`"version"`)) {
t.Fatalf("stdout = %s", buf.String())
}
if errBuf.Len() != 0 {
t.Fatalf("stderr inesperado: %s", errBuf.String())
}
}
Para regras de negócio, teste a função app.RunServer com table-driven tests e mocks/fakes. O comando Cobra vira uma casca fina — barata de testar e difícil de quebrar.
Versionamento e release do binário
Toda CLI de produção responde version com commit e data de build:
var (
version = "dev"
commit = "none"
date = "unknown"
)
// ldflags no build:
// -X example.com/jobctl/internal/cli.version=1.4.2
O GoReleaser preenche esses campos, gera checksums, SBOM e binários para linux/darwin/windows. Sem isso, o suporte vira “qual binário você baixou do Slack?”. Combine com govulncheck no CI antes de publicar a release.
Armadilhas comuns em CLI Go
- Log em stdout com
--output json— quebra o pipe. stderr para humano, stdout para máquina. os.Exitno meio doRunE— impede defers, flush de logs e testes. Retorneerrore saia só nomain.- Flags obrigatórias sem
MarkFlagRequired— o usuário descobre o erro tarde. Usecmd.MarkFlagRequired("file")ou valide noPreRunE. - Globals mutáveis para flags — dificulta teste paralelo. Prefira closures ou structs de opções por comando.
- Ignorar
contextemserve— Ctrl+C deixa conexões abertas. Veja graceful shutdown. - Misturar TUI e flags sem modo scriptável — se a ferramenta entra em menu interativo sem
stdinTTY, pipelines morrem. Ofereça caminho não interativo (Cobra puro) e opcionalmente um subcomandotuicom Bubble Tea. - Help em erro de rede — configure
SilenceUsagee mensagens curtas; detalhe fica no log com--verbose.
Cobra no dia a dia do mercado brasileiro
Em vagas de Go no Brasil — plataformas de pagamento, fintechs, e-commerce, cloud providers — é comum o time manter CLIs internas: provisionar tenant, reprocessar fila, emitir token de debug, rodar migration com guardrail. Dominar Cobra aparece em entrevistas de backend sênior como “como você estruturaria a ferramenta X?”. Monte no portfólio um jobctl pequeno com serve, migrate, version --output json, completions e release via GoReleaser; isso conversa bem com o guia de projetos para portfólio e com as vagas listadas no site.
Se você ainda está no começo da linguagem, encaixe CLI depois de módulos e de um HTTP server básico — a mesma disciplina de context, erros e testes se aplica.
Perguntas frequentes
O que é Cobra em Go?
Cobra é a biblioteca mais usada para construir CLIs em Go. Ela organiza comandos e subcomandos, gera help automático, aceita flags locais e persistentes, expõe completions para bash/zsh/fish/powershell e integra bem com Viper para configuração. Projetos como kubectl, Hugo e GitHub CLI popularizaram esse modelo de árvore de comandos.
Quando usar Cobra em vez do pacote flag?
Use o pacote flag da biblioteca padrão para uma ferramenta de um único comando com poucas opções. Passe para Cobra quando precisar de subcomandos (serve, migrate, version), help consistente, flags compartilhadas, completions e uma árvore de comandos que cresce com o produto. O tutorial introdutório de CLI mostra a progressão natural entre os dois.
Como Cobra e Viper trabalham juntos?
Cobra cuida da interface de linha de comando; Viper cuida da configuração. O padrão comum é bindar flags do Cobra no Viper e deixar o Viper resolver a precedência entre flag, variável de ambiente e arquivo YAML/JSON. Assim o mesmo valor pode vir de --port, PORT ou config.yaml. Veja o guia de configuração em Go.
Qual exit code uma CLI Go deve devolver?
Use 0 para sucesso. Use códigos diferentes de zero para falha: 1 para erro de negócio ou runtime, 2 para uso incorreto (flags inválidas, argumentos faltando). Em scripts e CI, o exit code é o contrato — nunca imprima o erro e saia com 0.
Como testar comandos Cobra?
Monte o *cobra.Command em teste, redirecione Out e Err para buffers, chame ExecuteC e assert sobre stdout, stderr e erro retornado. Extraia a lógica de negócio para funções que recebem context e dependências, para não acoplar testes ao parsing de flags. Combine com testes em tabela.
Conclusão
Cobra é o padrão de fato para CLI em Go porque impõe uma árvore de comandos legível, help previsível e extensão barata. Em produção, o diferencial não é “usar Cobra” — é respeitar o contrato: RunE, stdout limpo, stderr para log, exit codes corretos, ExecuteContext com sinais, Viper para config, testes com buffers e release reproduzível com GoReleaser.
Próximos passos na malha de ferramentas do site:
- Tutorial: CLI tools com flag e Cobra
- Configuração: Viper e variáveis de ambiente
- GoReleaser: binários, checksums e SBOM
- Bubble Tea: TUI interativa no terminal
- Graceful shutdown em produção
- slog: logging estruturado
- Testes em tabela
- Projetos Go para portfólio
- Vagas de Go no Brasil
Implemente um jobctl mínimo esta semana: version, serve com shutdown limpo e migrate --dry-run. Essa base cobre a maior parte do que entrevistas e times de plataforma esperam de uma CLI Go séria.