← Voltar para o blog

Cobra em Go: CLI de Produção com Subcomandos, Flags e Exit Codes

Cobra em Go na prática: root command, subcomandos, flags persistentes, Viper, exit codes, saída JSON, context, testes e completions para CLI de produção.

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árioEscolha
Um binário, 2–3 flags, sem subcomandoflag da stdlib
Subcomandos (serve, migrate, completion)Cobra
Flags globais (--config, --verbose) + locaisCobra (persistent + local)
Completions bash/zsh e help geradoCobra
UI interativa (listas, spinners, painéis)Bubble Tea (Cobra pode só lançar o modo TUI)
Distribuir binário versionado multi-OSCobra + 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() parseia os.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:

  1. RunE em vez de Run — erros sobem para o Execute e você controla o exit code.
  2. cmd.OutOrStdout() / cmd.ErrOrStderr() — permitem redirecionar saída em testes sem tocar em os.Stdout global.
  3. Lógica de negócio fora do RunE — o comando só traduz flags em parâmetros; a função de domínio recebe context.Context e 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.

StreamConteúdo
stdoutResultado consumível: JSON, tabela, ID criado, versão
stderrLog humano, progresso, avisos, stack resumida
exit 0Sucesso
exit 2Uso incorreto (flag inválida, args faltando)
exit 1Falha 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:

  1. Cobra declara a flag;
  2. Viper bind a flag e o env;
  3. 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):

  1. flag na linha de comando;
  2. variável de ambiente;
  3. arquivo de configuração;
  4. 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

  1. Log em stdout com --output json — quebra o pipe. stderr para humano, stdout para máquina.
  2. os.Exit no meio do RunE — impede defers, flush de logs e testes. Retorne error e saia só no main.
  3. Flags obrigatórias sem MarkFlagRequired — o usuário descobre o erro tarde. Use cmd.MarkFlagRequired("file") ou valide no PreRunE.
  4. Globals mutáveis para flags — dificulta teste paralelo. Prefira closures ou structs de opções por comando.
  5. Ignorar context em serve — Ctrl+C deixa conexões abertas. Veja graceful shutdown.
  6. Misturar TUI e flags sem modo scriptável — se a ferramenta entra em menu interativo sem stdin TTY, pipelines morrem. Ofereça caminho não interativo (Cobra puro) e opcionalmente um subcomando tui com Bubble Tea.
  7. Help em erro de rede — configure SilenceUsage e 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:

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.