A Real CLI

Configuration with Viper

LuettavaEsikatselu

Katselet ilmaista esikatseluoppituntia.

Goal

By the end of this lesson, Tasker reads its settings from four places: flags, environment variables, a YAML config file, and built-in defaults. Users can write their preferences once in ~/.tasker/config.yaml. A new tasker config command shows which settings Tasker is using and where they came from.

What you will learn

  • Why programs read settings from several sources, and which source wins
  • How to read a YAML config file with Viper
  • How to connect Cobra flags and environment variables to the same setting
  • How to run code before every command with PersistentPreRunE
  • How to check for an error type with errors.As
  • Why a Viper instance is better than Viper's global functions

Why this matters

Users do not want to type --file ~/work/tasks.json on every command. Servers do not want settings in code at all. In Part 2, your API reads its database address, port, and log level the same way: defaults in code, values from a file, and overrides from environment variables in Docker. The pattern you build here works for both.

Most tools follow this order. The first source that has a value wins:

  1. Flags: --file x.json. Most specific, for one run.
  2. Environment variables: TASKER_FILE=x.json. For one terminal session or one server.
  3. Config file: ~/.tasker/config.yaml. The user's permanent preferences.
  4. Defaults: written in the code.

Viper, a library by the same author as Cobra, implements this order for you.

Step 1: Add Viper

go get github.com/spf13/viper@latest

Viper reads JSON, TOML, and YAML files, among others. You use YAML here, because people find it easy to read and write by hand. A Tasker config file looks like this:

# Tasker settings
file: ~/work/tasks.json
list:
  open: true

YAML uses indentation for nesting. open is inside list, so its full key is list.open. Viper always uses dots to name nested keys. Lines that start with # are comments.

Step 2: Write the config loader

Create internal/cli/config.go:

package cli

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"

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

// loadConfig reads settings from the config file and the environment into v.
// If configFile is empty, it looks for config.yaml in ~/.tasker.
func loadConfig(v *viper.Viper, configFile string) error {
	v.SetDefault("list.open", false)

	v.SetEnvPrefix("TASKER")
	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	v.AutomaticEnv()

	if configFile != "" {
		v.SetConfigFile(configFile)
	} else {
		dir, err := taskerDir()
		if err != nil {
			return err
		}
		v.AddConfigPath(dir)
		v.SetConfigName("config")
		v.SetConfigType("yaml")
	}

	err := v.ReadInConfig()
	var notFound viper.ConfigFileNotFoundError
	if errors.As(err, &notFound) {
		return nil
	}
	if err != nil {
		return fmt.Errorf("read config: %w", err)
	}
	return nil
}

// taskerDir returns the folder where Tasker keeps its files: ~/.tasker
func taskerDir() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("find home folder: %w", err)
	}
	return filepath.Join(home, ".tasker"), nil
}

// expandHome replaces a leading "~" in path with the user's home folder.
func expandHome(path string) (string, error) {
	if path != "~" && !strings.HasPrefix(path, "~/") {
		return path, nil
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("find home folder: %w", err)
	}
	return filepath.Join(home, strings.TrimPrefix(path, "~")), nil
}

Section by section:

Defaults. v.SetDefault("list.open", false) is the lowest level of the order. It is used only when no other source has a value.

Environment variables. These three lines connect every setting to an environment variable:

  • SetEnvPrefix("TASKER") adds TASKER_ in front of each name, so Tasker does not read other programs' variables.
  • SetEnvKeyReplacer turns dots into underscores, because environment variable names cannot contain dots.
  • AutomaticEnv() tells Viper to check the environment every time you read a setting.

So file maps to TASKER_FILE, the same variable you have used since Lesson 3.3, and list.open maps to TASKER_LIST_OPEN.

The config file. If the user passes --config, Viper reads exactly that file. Otherwise, it searches for a file named config in ~/.tasker with the YAML format.

A missing config file is fine. Most users never create one. When Viper searches and finds nothing, it returns a viper.ConfigFileNotFoundError. Tasker ignores that error, but it reports every other error, such as broken YAML.

expandHome. People like to write ~/work/tasks.json. The terminal replaces ~ with the home folder when you type it in a command, but Go does not do that for text from a file. This helper does it.

Step 3: errors.As checks the error's type

Look at this part again:

var notFound viper.ConfigFileNotFoundError
if errors.As(err, &notFound) {
	return nil
}

In Lesson 2.4, you used errors.Is to check for a specific error value, such as task.ErrNotFound. Viper does not use a sentinel value here. It uses its own error type, a struct with extra fields, such as the folders it searched.

errors.As checks if the error, or any error wrapped inside it, has a certain type. If it does, it copies the error into your variable, so you can read its fields. You pass a pointer to the variable, because errors.As must write into it.

You want to knowUse
Is this error a specific value?errors.Is(err, task.ErrNotFound)
Is this error a specific type?errors.As(err, &target)

Both look through all layers of %w wrapping. Both return false when err is nil.

Step 4: Connect Viper to the root command

Open internal/cli/root.go. Delete internal/cli/path.go, because its job moves into root.go. Change the options struct:

// options holds the settings that every command can use.
type options struct {
	configFile string
	v          *viper.Viper
}

Change the start of NewRootCmd, and replace the --file flag definition:

	opts := &options{v: viper.New()}

	root := &cobra.Command{
		Use:   "tasker",
		Short: "Tasker is a simple task manager for your terminal",
		Long: `Tasker is a simple task manager for your terminal.

It stores tasks in a JSON file. By default, the file is ~/.tasker/tasks.json.

Settings come from these places. The first one that is set wins:
  1. flags, such as --file
  2. environment variables, such as TASKER_FILE
  3. the config file, ~/.tasker/config.yaml
  4. built-in defaults`,
		Version:       Version,
		SilenceUsage:  true,
		SilenceErrors: true,
		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
			return loadConfig(opts.v, opts.configFile)
		},
	}

	root.PersistentFlags().StringVar(&opts.configFile, "config", "", "path to the config file (default ~/.tasker/config.yaml)")
	root.PersistentFlags().String("file", "", "path to the task file (default ~/.tasker/tasks.json)")
	opts.v.BindPFlag("file", root.PersistentFlags().Lookup("file"))

Add newConfigCmd(opts) to the list in root.AddCommand. Then replace openStore with these two methods:

// dataPath returns the path of the task file from the settings,
// or ~/.tasker/tasks.json if no setting is given.
func (o *options) dataPath() (string, error) {
	if path := o.v.GetString("file"); path != "" {
		return expandHome(path)
	}
	dir, err := taskerDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, "tasks.json"), nil
}

// openStore opens the store that uses the task file.
func (o *options) openStore() (*task.Store, error) {
	path, err := o.dataPath()
	if err != nil {
		return nil, err
	}
	return task.Open(storage.NewFile(path))
}

Add "path/filepath" and "github.com/spf13/viper" to the imports.

What is new:

  • viper.New() creates a separate Viper instance. Viper also has global functions, such as viper.GetString, that use one shared hidden instance. Global state makes code hard to test, because every test changes the same values. With an instance in options, each NewRootCmd call gets fresh settings.
  • PersistentPreRunE runs before the RunE of every subcommand. It is the right place to load the config, because Cobra has parsed the flags by then, so opts.configFile already holds the --config value.
  • BindPFlag("file", ...) connects the --file flag to the file setting. When the user passes --file, Viper uses it. Otherwise, Viper falls back to the environment variable, then the config file. This replaces the hand-written if chain from Lesson 4.3.
  • o.v.GetString("file") reads the final value. The command does not care which source it came from.

Step 5: Use a setting in list

Open internal/cli/list.go. Remove the openOnly variable. Change how the --open flag is defined, and bind it:

	cmd.Flags().Bool("open", false, "show only open tasks")
	opts.v.BindPFlag("list.open", cmd.Flags().Lookup("open"))

In RunE, read the setting from Viper:

			tasks := task.Filter(store.List(), tag, opts.v.GetBool("list.open"))

Now --open, TASKER_LIST_OPEN, and list.open in the config file all control the same thing.

Step 6: A config command

Users often ask "Why does Tasker use this file?" A command that prints the active settings answers that question. Add this to internal/cli/config.go:

func newConfigCmd(opts *options) *cobra.Command {
	return &cobra.Command{
		Use:   "config",
		Short: "Show the settings Tasker uses",
		Args:  cobra.NoArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			out := cmd.OutOrStdout()

			used := opts.v.ConfigFileUsed()
			if used == "" {
				used = "(none)"
			}
			path, err := opts.dataPath()
			if err != nil {
				return err
			}

			fmt.Fprintln(out, "config file:", used)
			fmt.Fprintln(out, "task file:  ", path)
			fmt.Fprintln(out, "list.open:  ", opts.v.GetBool("list.open"))
			return nil
		},
	}
}

Run go mod tidy, then build:

go mod tidy
go build ./cmd/tasker

Step 7: Try every level

First, without a config file. Unset TASKER_FILE if it is still set from earlier lessons (unset TASKER_FILE, or Remove-Item Env:TASKER_FILE in PowerShell).

./tasker config
config file: (none)
task file:   /home/ana/.tasker/tasks.json
list.open:   false

Add some tasks so you can see the filter work:

./tasker add Buy milk
./tasker add Pay rent
./tasker done 1

Now create ~/.tasker/config.yaml:

list:
  open: true
./tasker list
2. [ ] Pay rent

The config file turned the filter on. Now override it with a flag, and then with an environment variable:

./tasker list --open=false
TASKER_LIST_OPEN=false ./tasker list

Both print:

1. [x] Buy milk
2. [ ] Pay rent

Boolean flags: --open alone means true. To set a boolean flag to false, write --open=false with an equals sign.

Now add a task file to the config:

file: ~/work/tasks.json
list:
  open: true
./tasker config
config file: /home/ana/.tasker/config.yaml
task file:   /home/ana/work/tasks.json
list.open:   true

Finally, try a broken config file. Use --config to point Tasker at a file that contains file: [oops:

error: read config: While parsing config: yaml: line 1: did not find expected ',' or ']'

Tasker stops with a clear error instead of silently using wrong settings.

Why it works: one setting, many sources

Every setting has one key, such as list.open. Viper connects that key to a default, a config file entry, an environment variable, and a flag. The commands only ask Viper for the key. They do not know or care where the value came from. When you add a new setting, you add it in one place, and all four sources work right away.

In Part 2, the API server uses the same Viper code for its settings. In Docker, you set them with environment variables, and on your computer you use a config file.

Exercises

  1. A new setting. Add a list.json setting, bound to the --json flag, so a user can choose JSON output as their default.
  2. Show the source. Change tasker config so it also prints the value of TASKER_FILE from the environment, or (not set). This helps users see why a setting wins.
  3. A config file in the project folder. Add v.AddConfigPath(".") before the home folder path. Viper searches the folders in the order you add them. Create config.yaml in your current folder and run tasker config. Which file wins?
  4. errors.As practice. In File.Load, an os.ReadFile error for a missing permission is an *fs.PathError. Use errors.As in the config command to print only the Path field of such an error.
  5. Test the order. Set file in the config file, TASKER_FILE in the environment, and --file on the command line, each to a different path. Run tasker config and remove them one at a time. Write down which value wins each time.

Checkpoint

You have finished Module 4 if you can:

  • Name the four sources of settings and their order
  • Read a YAML config file with Viper and ignore a missing file
  • Bind a Cobra flag and an environment variable to the same Viper key
  • Explain when PersistentPreRunE runs
  • Explain the difference between errors.Is and errors.As
  • Explain why a Viper instance is better than the global Viper functions