Introduction

The Go standard library can of course write command line tools:

package main

import (
	"fmt"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("缺少命令")
		return
	}

	switch os.Args[1] {
	case "version":
		fmt.Println("v1.0.0")
	default:
		fmt.Println("未知命令:", os.Args[1])
	}
}

It’s okay for gadgets to be written like this. Once the command becomes like this, the code will quickly become messy:

todoctl add "写周报" --priority high --tag work
todoctl list --done=false --limit 20
todoctl done 3
todoctl completion zsh

What needs to be dealt with at this time is not just os.Args

子命令怎么组织?
全局参数和局部参数怎么区分?
必填参数怎么提示?
参数不合法时怎么返回错误?
help、version、completion 怎么生成?
命令层和业务代码怎么分开?

Cobra solves exactly these problems.

In short:

Cobra 把命令行工具组织成一棵命令树,每个节点负责一类动作,每个动作再配自己的参数、校验和执行逻辑。

What problems is Cobra suitable for solving?

Cobra is a commonly used CLI framework in Go. The package name is:

github.com/spf13/cobra

Many command line programs will use a similar structure:

kubectl get pods
kubectl describe pod nginx
docker image ls
docker container stop app

This structure is not a single command, but a tree:

app
├── user
│   ├── add
│   └── list
├── server
│   ├── start
│   └── stop
└── version

The core value of Cobra is to build this tree and provide supporting capabilities:

  • Subcommands and multi-level subcommands
  • local flags and persistent flags
  • Position parameter verification
  • --help--version, usage copywriting
  • RunE error return
  • Hooks before and after command execution
  • shell autocomplete script
  • Integrate with configuration libraries such as Viper

Three core concepts of Command, Args and Flag

The most important types in Cobra are cobra.Command

var rootCmd = &cobra.Command{
	Use:   "todoctl",
	Short: "一个待办事项命令行工具",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("hello cobra")
	},
}

A command mainly consists of three parts:

Command:命令本身,比如 add、list、done
Args:位置参数,比如 add 后面的任务标题
Flag:选项参数,比如 --priority high、--done=false

Positional parameters do not take -

todoctl add "写周报"

Here's "写周报" It's Args.

flag band - or --

todoctl add "写周报" --priority high -t work

Here's --priority and -t It’s Flag.

Installation method

The project only requires the Cobra library:

go get github.com/spf13/cobra@latest

If you want to use scaffolding to generate a directory, you can also install it cobra-cli

go install github.com/spf13/cobra-cli@latest

Scaffolding is not a necessity. When it comes to understanding Cobra itself, it's much clearer to write a small project by hand.

First minimal example

Let’s look at a single-file version first:

package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

func main() {
	rootCmd := &cobra.Command{
		Use:   "hello",
		Short: "第一个 Cobra 程序",
		Run: func(cmd *cobra.Command, args []string) {
			fmt.Println("Hello Cobra")
		},
	}

	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

run:

go run main.go

Output:

Hello Cobra

View help:

go run main.go --help

Cobra will be based on UseShort, flag, subcommand automatically generates help content.

How to write Use, Short, Long, Example

cobra.Command There are four commonly used description fields:

var addCmd = &cobra.Command{
	Use:   "add <title>",
	Short: "新增待办事项",
	Long: `新增一条待办事项。

标题来自位置参数,优先级、标签、完成状态通过 flag 控制。`,
	Example: `  todoctl add "写周报"
  todoctl add "修复支付回调" --priority high --tag backend`,
}

These fields are not decorations and will appear in --help inside.

Common writing methods:

Use:写命令名和参数形状,比如 add <title>
Short:一句话说明,出现在命令列表里
Long:较长说明,出现在当前命令 help 里
Example:给出真实命令,减少使用成本

The difference between Run and RunE

Run No error is returned:

Run: func(cmd *cobra.Command, args []string) {
	fmt.Println("执行成功")
}

RunE Return error:

RunE: func(cmd *cobra.Command, args []string) error {
	if len(args) == 0 {
		return fmt.Errorf("缺少任务标题")
	}
	return nil
}

Engineering code is more recommended RunE

The reason is simple: when the command execution fails, the errors can be handed over to Execute Processing, there is no need to place it anywhere in the business function os.Exit

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

The command layer returns an error, and the entry layer determines the exit code, so the structure is cleaner.

Local Flag and Persistent Flag

Cobra has two common types of flags.

Local flags only take effect on the current command:

addCmd.Flags().StringVarP(&priority, "priority", "p", "normal", "任务优先级")

Persistent flags take effect on the current command and all subcommands:

rootCmd.PersistentFlags().StringVar(&dataFile, "data", "tasks.json", "数据文件路径")

example:

todoctl --data ./tmp/tasks.json add "写周报"
todoctl --data ./tmp/tasks.json list
todoctl --data ./tmp/tasks.json done 1

--data It is a global configuration and is suitable for root. PersistentFlags inside.

--priority It is only related to new tasks and is suitable to be placed in add commanding Flags inside.

Parameter verification Args

Position parameter verification passed Args Field completed.

Common built-in validations:

Args: cobra.NoArgs
Args: cobra.ExactArgs(1)
Args: cobra.MinimumNArgs(1)
Args: cobra.MaximumNArgs(3)
Args: cobra.RangeArgs(1, 3)

New tasks require at least one title:

Args: cobra.MinimumNArgs(1)

An ID must be passed to complete the task:

Args: cobra.ExactArgs(1)

You can also write custom verification:

Args: func(cmd *cobra.Command, args []string) error {
	if len(args) != 1 {
		return fmt.Errorf("需要传入任务 ID")
	}
	if _, err := strconv.Atoi(args[0]); err != nil {
		return fmt.Errorf("任务 ID 必须是数字:%s", args[0])
	}
	return nil
}

Practical project: todoctl to-do tool

Write a executable below todoctl

Function:

todoctl add "写周报" --priority high --tag work
todoctl add "修复登录 bug" -p high -t backend -t bug
todoctl list
todoctl list --done=false
todoctl done 1
todoctl completion zsh

The data is saved to a local JSON file for easy observation of the complete process.

Project structure:

todoctl/
├── cmd/
│   ├── add.go
│   ├── completion.go
│   ├── done.go
│   ├── list.go
│   └── root.go
├── internal/
│   └── task/
│       └── store.go
├── go.mod
└── main.go

Initialization project:

mkdir todoctl
cd todoctl
go mod init todoctl
go get github.com/spf13/cobra@latest

main.go: program entry

main.go Just do one thing: execute the root command.

package main

import "todoctl/cmd"

func main() {
	cmd.Execute()
}

Keep the entrance thin so that the startup logic will not be affected when extending commands later.

internal/task/store.go: business and storage

The command layer does not directly operate JSON details, and the business code is placed in internal/task

package task

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"slices"
	"strings"
	"time"
)

type Task struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Priority  string    `json:"priority"`
	Tags      []string  `json:"tags"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
	DoneAt    time.Time `json:"done_at,omitempty"`
}

type Store struct {
	File string
}

type AddInput struct {
	Title    string
	Priority string
	Tags     []string
}

var allowedPriorities = []string{"low", "normal", "high"}

func (s Store) Load() ([]Task, error) {
	data, err := os.ReadFile(s.File)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return []Task{}, nil
		}
		return nil, fmt.Errorf("读取任务文件失败:%w", err)
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, fmt.Errorf("解析任务文件失败:%w", err)
	}
	return tasks, nil
}

func (s Store) Save(tasks []Task) error {
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return fmt.Errorf("序列化任务失败:%w", err)
	}

	if err := os.WriteFile(s.File, data, 0o644); err != nil {
		return fmt.Errorf("写入任务文件失败:%w", err)
	}
	return nil
}

func (s Store) Add(input AddInput) (Task, error) {
	title := strings.TrimSpace(input.Title)
	if title == "" {
		return Task{}, fmt.Errorf("任务标题不能为空")
	}

	priority := strings.TrimSpace(input.Priority)
	if priority == "" {
		priority = "normal"
	}
	if !slices.Contains(allowedPriorities, priority) {
		return Task{}, fmt.Errorf("不支持的优先级:%s", priority)
	}

	tasks, err := s.Load()
	if err != nil {
		return Task{}, err
	}

	task := Task{
		ID:        nextID(tasks),
		Title:     title,
		Priority:  priority,
		Tags:      cleanTags(input.Tags),
		CreatedAt: time.Now(),
	}

	tasks = append(tasks, task)
	if err := s.Save(tasks); err != nil {
		return Task{}, err
	}
	return task, nil
}

func (s Store) MarkDone(id int) (Task, error) {
	tasks, err := s.Load()
	if err != nil {
		return Task{}, err
	}

	for i := range tasks {
		if tasks[i].ID == id {
			tasks[i].Done = true
			tasks[i].DoneAt = time.Now()
			if err := s.Save(tasks); err != nil {
				return Task{}, err
			}
			return tasks[i], nil
		}
	}

	return Task{}, fmt.Errorf("任务不存在:%d", id)
}

func nextID(tasks []Task) int {
	id := 1
	for _, task := range tasks {
		if task.ID >= id {
			id = task.ID + 1
		}
	}
	return id
}

func cleanTags(tags []string) []string {
	result := make([]string, 0, len(tags))
	for _, tag := range tags {
		tag = strings.TrimSpace(tag)
		if tag != "" {
			result = append(result, tag)
		}
	}
	return result
}

Here are a few details:

  • Store Only care about data file path
  • AddInput Is the input structure of the new task
  • Load Returns an empty slice when the file does not exist
  • For business errors fmt.Errorf Return to command layer
  • The command layer does not know how to read and write JSON

cmd/root.go: root command and global parameters

The root command is responsible for mounting global parameters, versions, and error handling strategies.

package cmd

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

var (
	dataFile string
	verbose  bool
)

var rootCmd = &cobra.Command{
	Use:           "todoctl",
	Short:         "管理本地待办事项",
	Long:          "todoctl 是一个基于 Cobra 的本地待办事项命令行工具。",
	Version:       "v1.0.0",
	SilenceUsage:  true,
	SilenceErrors: true,
	RunE: func(cmd *cobra.Command, args []string) error {
		return cmd.Help()
	},
	PersistentPreRun: func(cmd *cobra.Command, args []string) {
		if verbose {
			fmt.Fprintf(os.Stderr, "data file: %s\n", dataFile)
		}
	},
}

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

func init() {
	rootCmd.PersistentFlags().StringVar(&dataFile, "data", "tasks.json", "数据文件路径")
	rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "输出调试信息")
}

SilenceUsage and SilenceErrors Very practical.

By default, Cobra may automatically print usage or error text on errors. In projects, you usually want to control the output yourself, so you can directly turn off automatic output in the root command.

SilenceUsage:  true,
SilenceErrors: true,

Version Will make the root command automatically support:

todoctl --version

cmd/add.go: Add new tasks

The new task uses positional parameters, local flags, required verification,RunE

package cmd

import (
	"fmt"
	"strings"

	"todoctl/internal/task"

	"github.com/spf13/cobra"
)

var (
	addPriority string
	addTags     []string
)

var addCmd = &cobra.Command{
	Use:   "add <title>",
	Short: "新增待办事项",
	Long:  "新增一条待办事项,标题来自位置参数,优先级和标签来自 flag。",
	Example: `  todoctl add "写周报"
  todoctl add "修复登录 bug" --priority high --tag backend --tag bug`,
	Args: cobra.MinimumNArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		store := task.Store{File: dataFile}
		created, err := store.Add(task.AddInput{
			Title:    strings.Join(args, " "),
			Priority: addPriority,
			Tags:     addTags,
		})
		if err != nil {
			return err
		}

		fmt.Printf("已新增 #%d [%s] %s\n", created.ID, created.Priority, created.Title)
		return nil
	},
}

func init() {
	rootCmd.AddCommand(addCmd)

	addCmd.Flags().StringVarP(&addPriority, "priority", "p", "normal", "任务优先级:low、normal、high")
	addCmd.Flags().StringSliceVarP(&addTags, "tag", "t", nil, "任务标签,可多次传入")
}

run:

go run . add "写周报" --priority high --tag work

Output:

已新增 #1 [high] 写周报

Add one more:

go run . add "修复登录 bug" -p high -t backend -t bug

Output:

已新增 #2 [high] 修复登录 bug

StringSliceVarP Supports two writing methods: comma and value repetition:

todoctl add "整理文档" -t doc -t work
todoctl add "整理文档" -t doc,work

cmd/list.go: list query

The list command demonstrates another use of local flags: filtering and paging.

package cmd

import (
	"fmt"
	"os"
	"strings"
	"text/tabwriter"

	"todoctl/internal/task"

	"github.com/spf13/cobra"
)

var (
	listDone     string
	listLimit    int
	listPriority string
)

var listCmd = &cobra.Command{
	Use:   "list",
	Short: "查看待办事项列表",
	Args:  cobra.NoArgs,
	RunE: func(cmd *cobra.Command, args []string) error {
		store := task.Store{File: dataFile}
		tasks, err := store.Load()
		if err != nil {
			return err
		}

		rows := filterTasks(tasks)
		if listLimit > 0 && len(rows) > listLimit {
			rows = rows[:listLimit]
		}
		if len(rows) == 0 {
			fmt.Println("暂无任务")
			return nil
		}

		w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
		fmt.Fprintln(w, "ID\t状态\t优先级\t标题\t标签")
		for _, item := range rows {
			status := "待办"
			if item.Done {
				status = "完成"
			}
			fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n",
				item.ID,
				status,
				item.Priority,
				item.Title,
				strings.Join(item.Tags, ","),
			)
		}
		return w.Flush()
	},
}

func init() {
	rootCmd.AddCommand(listCmd)

	listCmd.Flags().StringVar(&listDone, "done", "", "按完成状态过滤:true、false")
	listCmd.Flags().StringVarP(&listPriority, "priority", "p", "", "按优先级过滤")
	listCmd.Flags().IntVarP(&listLimit, "limit", "n", 0, "最多显示条数")
}

func filterTasks(tasks []task.Task) []task.Task {
	rows := make([]task.Task, 0, len(tasks))
	for _, item := range tasks {
		if listDone == "true" && !item.Done {
			continue
		}
		if listDone == "false" && item.Done {
			continue
		}
		if listPriority != "" && item.Priority != listPriority {
			continue
		}
		rows = append(rows, item)
	}
	return rows
}

run:

go run . list

The output is similar to:

ID  状态  优先级  标题           标签
1   待办  high    写周报         work
2   待办  high    修复登录 bug   backend,bug

See only unfinished tasks:

go run . list --done=false

Only look at high priority tasks:

go run . list --priority high

cmd/done.go: complete the task

A digital ID is required to complete the task. Use custom here Args Verification makes errors more clear.

package cmd

import (
	"fmt"
	"strconv"

	"todoctl/internal/task"

	"github.com/spf13/cobra"
)

var doneCmd = &cobra.Command{
	Use:   "done <id>",
	Short: "标记任务为完成",
	Args: func(cmd *cobra.Command, args []string) error {
		if len(args) != 1 {
			return fmt.Errorf("需要传入一个任务 ID")
		}
		if _, err := strconv.Atoi(args[0]); err != nil {
			return fmt.Errorf("任务 ID 必须是数字:%s", args[0])
		}
		return nil
	},
	RunE: func(cmd *cobra.Command, args []string) error {
		id, _ := strconv.Atoi(args[0])

		store := task.Store{File: dataFile}
		updated, err := store.MarkDone(id)
		if err != nil {
			return err
		}

		fmt.Printf("已完成 #%d %s\n", updated.ID, updated.Title)
		return nil
	},
}

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

run:

go run . done 1

Output:

已完成 #1 写周报

Check it out again:

go run . list

The output is similar to:

ID  状态  优先级  标题           标签
1   完成  high    写周报         work
2   待办  high    修复登录 bug   backend,bug

When parameters are wrong:

go run . done abc

Output:

任务 ID 必须是数字:abc

cmd/completion.go: Generate auto-completion script

Cobra supports generating bash, zsh, fish, and powershell completion scripts.

package cmd

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

var completionCmd = &cobra.Command{
	Use:   "completion [bash|zsh|fish|powershell]",
	Short: "生成 shell 自动补全脚本",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		switch args[0] {
		case "bash":
			return rootCmd.GenBashCompletion(os.Stdout)
		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:%s", args[0])
		}
	},
}

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

Generate zsh completion:

go run . completion zsh > _todoctl

The actual installation path is related to the shell configuration. The command itself is only responsible for outputting the script to standard output.

Run the complete demo

After the complete project is written, it can be verified according to the following process:

go run . --help
go run . --version
go run . add "写周报" --priority high --tag work
go run . add "修复登录 bug" -p high -t backend -t bug
go run . list
go run . done 1
go run . list --done=true
go run . completion bash

It can also be compiled into a binary:

go build -o todoctl .
./todoctl add "打包发布" -p normal
./todoctl list

How to organize the command tree

Cobra command depends on AddCommand Organizing parent-child relationships.

rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(listCmd)
rootCmd.AddCommand(doneCmd)
rootCmd.AddCommand(completionCmd)

If you have more complex business modules, you can continue to nest them:

rootCmd.AddCommand(userCmd)
userCmd.AddCommand(userCreateCmd)
userCmd.AddCommand(userListCmd)

Corresponding command:

app user create
app user list

The parent command usually only performs grouping and does not necessarily perform specific operations.

var userCmd = &cobra.Command{
	Use:   "user",
	Short: "用户管理",
	RunE: func(cmd *cobra.Command, args []string) error {
		return cmd.Help()
	},
}

Flag reading method: bind variables and temporary reading

There are two common ways to read flags.

The first is bind variables:

var port int

serveCmd.Flags().IntVarP(&port, "port", "p", 8080, "监听端口")

Use variables directly during execution:

fmt.Println(port)

The second one is in RunE It reads:

port, err := cmd.Flags().GetInt("port")
if err != nil {
	return err
}
fmt.Println(port)

Bind variables are commonly used for simple commands and commonly used for dynamic commands or test scenarios. GetXxx

Note how the persistent flag is read:

value, err := cmd.Flags().GetString("data")

Cobra will merge the inherited persistent flags into the flag collection visible to the current command, so it can also be read in subcommands.

required flag

Some parameters are suitable as flags, but must be passed.

For example, issuing a command must include the image name:

var image string

deployCmd.Flags().StringVar(&image, "image", "", "镜像名称")
_ = deployCmd.MarkFlagRequired("image")

run:

app deploy

Will prompt:

required flag(s) "image" not set

MarkFlagRequired Returns an error, used in the example _ = It's for simplicity. Real projects can check for this error during the initialization phase.

PreRun, Run, PostRun life cycle

Cobra supports hooks before and after command execution:

var serveCmd = &cobra.Command{
	Use: "serve",
	PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("初始化配置")
		return nil
	},
	PreRunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("检查端口")
		return nil
	},
	RunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("启动服务")
		return nil
	},
	PostRunE: func(cmd *cobra.Command, args []string) error {
		fmt.Println("释放资源")
		return nil
	},
}

Common uses:

PersistentPreRunE:读取配置、初始化日志、创建客户端
PreRunE:检查当前命令自己的前置条件
RunE:执行主逻辑
PostRunE:清理临时资源、输出统计信息

Don't put time-consuming initialization in init() inside.init() Suitable for registering commands and flags, the actions that really need to be executed are placed in hooks or RunE

How to put error handling

Recommended structure:

RunE: func(cmd *cobra.Command, args []string) error {
	result, err := service.DoSomething()
	if err != nil {
		return err
	}
	fmt.Println(result)
	return nil
}

It is not recommended to exit directly in every command:

Run: func(cmd *cobra.Command, args []string) {
	if err := service.DoSomething(); err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}

reason:

RunE 方便测试
RunE 方便统一错误输出
RunE 方便上层决定退出码
RunE 避免业务层直接调用 os.Exit

Unified processing at the entrance layer:

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

Don’t stuff too many services into the command layer

The command layer is suitable for doing these things:

解析 flag
校验 Args
组装输入结构
调用 service
把结果打印成 CLI 输出

The business layer is suitable for doing these things:

读写文件
访问数据库
调用 HTTP API
执行领域规则
返回结构化结果和错误

Compare this:

RunE: func(cmd *cobra.Command, args []string) error {
	store := task.Store{File: dataFile}
	created, err := store.Add(task.AddInput{
		Title:    strings.Join(args, " "),
		Priority: addPriority,
		Tags:     addTags,
	})
	if err != nil {
		return err
	}
	fmt.Printf("已新增 #%d [%s] %s\n", created.ID, created.Priority, created.Title)
	return nil
}

This command code is only responsible for "converting command line input into business input" and does not directly read or write JSON.

How to match Cobra and Viper

Cobra is responsible for commands and parameters, and Viper is responsible for configuration.

Typical scenario:

flag:--port 8080
环境变量:APP_PORT=8080
配置文件:config.yaml
默认值:8080

Simplified example:

package main

import (
	"fmt"
	"os"

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

var cfgFile string

func main() {
	rootCmd := &cobra.Command{
		Use:          "server",
		SilenceUsage: true,
		PreRunE: func(cmd *cobra.Command, args []string) error {
			if cfgFile != "" {
				viper.SetConfigFile(cfgFile)
			} else {
				viper.SetConfigName("config")
				viper.SetConfigType("yaml")
				viper.AddConfigPath(".")
			}
			viper.SetEnvPrefix("APP")
			viper.AutomaticEnv()
			return viper.ReadInConfig()
		},
		Run: func(cmd *cobra.Command, args []string) {
			fmt.Println("port:", viper.GetInt("port"))
		},
	}

	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "配置文件路径")
	rootCmd.Flags().Int("port", 8080, "监听端口")
	_ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port"))

	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

Configuration file:

port: 9090

run:

go run . --config config.yaml

Viper is not a required component of Cobra. It is only necessary to introduce them when the number of configuration sources increases.

Test Cobra commands

Cobra commands can be tested directly. The key is to set parameters and output buffers for the command.

package cmd

import (
	"bytes"
	"testing"

	"github.com/spf13/cobra"
)

func TestVersionCommand(t *testing.T) {
	cmd := &cobra.Command{
		Use:     "app",
		Version: "v1.0.0",
	}

	var out bytes.Buffer
	cmd.SetOut(&out)
	cmd.SetErr(&out)
	cmd.SetArgs([]string{"--version"})

	if err := cmd.Execute(); err != nil {
		t.Fatal(err)
	}
	if got := out.String(); got != "app version v1.0.0\n" {
		t.Fatalf("unexpected output: %q", got)
	}
}

When testing business commands, you can point the data file to the temporary directory:

dataFile = t.TempDir() + "/tasks.json"

This way the tests won't pollute local files.

Common mistake 1: Mixing flag and args

This command:

todoctl add "写周报" --priority high

Take it apart and see:

add:命令
"写周报":位置参数 args
--priority high:flag

Core objects like titles are usually suitable as positional parameters.

Modification information such as filtering, switching, and configuration are usually suitable for flags.

Common mistake 2: All commands share a bunch of global variables

You can use global variables to bind flags, but don't pile all business status into them.

Something suitable for a global variable:

flag 绑定变量
rootCmd、addCmd 等命令对象
版本号

What is not suitable for global variables:

数据库连接
请求上下文
中间计算结果
业务缓存

The latter is more suitable to be placed in service, client and store. RunE Or created and passed in a hook.

Common Mistake 3: The parent command has no behavior

When the parent command only performs grouping, there may be no output when run directly.

A friendlier approach would be to show help:

var userCmd = &cobra.Command{
	Use:   "user",
	Short: "用户管理",
	RunE: func(cmd *cobra.Command, args []string) error {
		return cmd.Help()
	},
}

Execute like this:

app user

will see user What are the subcommands below.

Common mistake 4: Doing real business in init

init() Suitable for registration:

func init() {
	rootCmd.AddCommand(addCmd)
	addCmd.Flags().StringVarP(&addPriority, "priority", "p", "normal", "任务优先级")
}

Not suitable for these things:

读取配置文件
连接数据库
请求远程接口
创建大对象缓存

These actions occur while the package is loading, and both testing and command completion may be slowed down. A more appropriate location is PersistentPreRunEPreRunE or RunE

Common Mistake 5: Print a large section of usage when making an error

If the parameters are wrong, the complete help will be printed, which can easily drown out the real errors.

The root command can be set like this:

SilenceUsage:  true,
SilenceErrors: true,

Then output the error uniformly:

if err := rootCmd.Execute(); err != nil {
	fmt.Fprintln(os.Stderr, err)
	os.Exit(1)
}

When help is needed, users proactively execute:

todoctl add --help

Engineering practice suggestions

The Cobra project can be organized along this lines:

cmd/
  root.go          根命令、全局 flag、版本、Execute
  add.go           add 子命令
  list.go          list 子命令
  completion.go    补全命令
internal/
  service/         业务服务
  repository/      数据访问
  config/          配置加载
main.go            程序入口

A few practical principles:

  • Command file split by subcommands
  • cmd The package only does command line adaptation
  • Put complex logic into internal
  • Business function returns error, for command layer RunE transfer
  • global configuration PersistentFlags
  • Single command configuration Flags
  • For positional parameters Args explicit verification
  • The parent command only returns when grouping cmd.Help()
  • Introduce Viper when configuration files are needed

When Not to Need Cobra

Not all command line programs require Cobra.

In the following scenarios, it may be easier to use the standard library:

只有一两个参数
没有子命令
不会长期维护
只是一次性脚本

standard library flag Sufficient for handling simple parameters.

Cobra is more suitable for these scenarios:

有多个子命令
需要清晰 help
需要补全脚本
需要版本命令
需要复杂参数校验
命令行工具会长期扩展

Summarize

The focus of Cobra is not to "save a few lines of parameter parsing code", but to give CLI programs a clear structure.

It can be understood as three layers:

命令树:root、子命令、多级子命令
输入层:args、flags、校验、help、completion
执行层:RunE、钩子、错误返回、业务调用

When writing gadgets,os.Args or standard library flag Very convenient.

When writing maintainable CLI applications, Cobra is more like a skeleton: how to hang commands, how to collect parameters, how to return errors, and how to generate help all have fixed positions.

A truly easy-to-maintain Cobra project is generally not about how coolly written the commands are, but about clear boundaries:

cmd 负责命令行输入输出
internal 负责真实业务
RunE 负责把两边接起来

Grab this thread and Cobra won't become another complex piece of code.