Designing Command-Line Tools People Love

Carolyn Van Slyck
Senior Software Engineer at Microsoft

Often CLIs aren't designed,
functionality is added haphazardly

Design Goals

  • Predictable
  • Task oriented
  • Friendly to both humans and scripts
  • New contributor launchpad


Docker Version Manager




Command Design

Pick a Grammar

Understand precedent in your ecosystem

  • svcat follows kubectl
  • dvm followed nvm (for a while)
  • dep doesn't follow glide, npm, etc

Let's design a CLI!


$ emote add emoticon gopher --value ʕ •ᴥ•ʔ
added custom emoticon "gopher"

$ emote delete emoticon anxious
deleted custom emoticon "anxious"

$ emote add repo funk --url
added 100 emoticons

$ emote list repos
NAME         URL                                    SIZE
funk          100 

$ emote list repos --output json

$ emote list
NAME               VALUE
shrug              ¯\_(ツ)_/¯
tableflip          (╯°□°)╯︵ ┻━┻
monocle            ಠ_ರೃ

$ emote shrug
¯\_(ツ)_/¯ copied to the clipboard

Domain vs. Grammar

Use your judgement about the domain when breaking with the grammar

  • emote list
  • emote shrug

Make tasks easier,
don't simply wrap an API

code generation isn't enough


  • spf13/cobra - Commands and Flags
  • spf13/viper - Configuration Management
  • spf13/afero - File System Abstraction

Thank you, Steve Francia! 💖

Getting Started with Cobra

package main

import ""

func newRootCmd(args []string) *cobra.Command {
	cmd := &cobra.Command{
		Use:          "helm",
		Short:        "The Helm package manager for Kubernetes.",
	out := cmd.OutOrStdout()
		newInstallCmd(nil, out), // helm install
		newListCmd(nil, out), // helm list
	return cmd

func main() {
	cmd := newRootCmd(os.Args[1:])
	if err := cmd.Execute(); err != nil {

From helm

Read Config Files of Any Type with Viper

type Manifest struct {
	Name    string `mapstructure:"name"` // mapstructure handles yaml, toml, json, hcl, etc
	Version string `mapstructure:"version"`

func Load(name, dir string) (*Manifest, error) {
	v := viper.New()
	if name == "" {
	} else {
		v.SetConfigFile(filepath.Join(dir, name))
	err := v.ReadInConfig()
	if err != nil {
		return nil, fmt.Errorf("Error finding duffle config file: %s", err)

	m := &Manifest{}
	return m, nil

From duffle

Replace ioutil with Afero


// vs.

var fs = afero.NewOsFs()

Package Structure

  • cmd/* is the wiring
  • pkg/* is the SDK

cmd/* package

  • Isolates dependencies on cli frameworks
  • Anything in here can't be exported
  • This stuff is hard to test properly

Keep this as small as possible

pkg/* package

  • Make functions that are 1:1 the commands in your CLI
  • Create happy little packages for everything
  • Hide your band-aides and API wrappers in here

Forget this is a CLI and follow your dreams 🌈

Wiring in Main Package

package main

import (

cmd := &cobra.Command{
	Use:   "mixins",
	Short: "List installed mixins",
	PreRunE: func(cmd *cobra.Command, args []string) error {
		var err error
		opts.format, err = printer.ParseFormat(opts.rawFormat)
		return err
	RunE: func(cmd *cobra.Command, args []string) error {
		o := printer.PrintOptions{ Format: opts.format }
		return p.PrintMixins(o)

From porter list mixins

Dependency Injection

Porter's Context

package context

// Context holds references to external systems that need to be captured or modified in tests
type Context struct {
	Debug      bool
	FileSystem *afero.Afero
	In         io.Reader
	Out        io.Writer
	Err        io.Writer
	NewCommand CommandBuilder // Abstraction for executing binaries

From Porter

Yes, context is a horrible name. Call it whatever you like. 😅

Composing Porter's SDK

package config

// Config holds flags, env vars and config files contents
type Config struct {
	Manifest *Manifest


package porter

// Porter is the logic behind the porter client.
type Porter struct {

Using Porter's SDK

p := porter.NewPorter()

// Manifest cames from Config

// Out comes from Context
fmt.Fprintf(p.Out, "\nWriting Dockerfile =======>\n") 

// FileSystem comes from Context
err = p.FileSystem.WriteFile("Dockerfile", contents, 0644) 

Testing Strategies

Test Goals

  • Exercise the CLI validation, output and formatting
  • Avoid retesting the SDK from the CLI's tests
  • Safety net for new contributors
  • Remove excuses for people who refuse to do "frontend stuff" 😇

Porter Flag Validation


package main

func buildListMixinsCommand(p *porter.Porter) *cobra.Command {
	opts := struct {
		rawFormat string
		format    printer.Format
	cmd := &cobra.Command{
		Use:   "mixins",
		Short: "List installed mixins",
		PreRunE: func(cmd *cobra.Command, args []string) error {
			var err error
			opts.format, err = printer.ParseFormat(opts.rawFormat)
			return err

From Porter

Porter Flag Validation Test


func TestBuildListMixinsCommand_BadFormat(t *testing.T) {
	p := porter.NewTestPorter(t)
	cmd := buildListMixinsCommand(p.Porter)
	cmd.ParseFlags([]string{"--output", "flarts"})

	err := cmd.PreRunE(cmd, []string{})

	require.NotNil(t, err)
	require.Contains(t, err.Error(), "invalid format: flarts")

From Porter

Test Context


type TestContext struct {

	input  *bytes.Buffer
	output *bytes.Buffer
	T      *testing.T

// NewTestContext initializes a configuration suitable for testing, 
// with the output buffered, and an in-memory file system.
func NewTestContext(t *testing.T) *TestContext {

From Porter

Test SDK


type TestPorter struct {
	TestConfig *config.TestConfig

// NewTestPorter initializes a porter test client, 
// with the output buffered, and an in-memory file system.
func NewTestPorter(t *testing.T) *TestPorter {

From Porter

Capturing Output in Tests


func TestPrintVersion(t *testing.T) {
	pkg.Commit = "abc123"
	pkg.Version = "v1.2.3"
	p := NewTestPorter(t)


	// Use our test structs to grab the captured output
	gotOutput := p.TestConfig.TestContext.GetOutput()

	wantOutput := "porter v1.2.3 (abc123)"
	if !strings.Contains(gotOutput, wantOutput) {
		t.Fatalf("invalid output:\nWANT:\t%q\nGOT:\t%q\n", wantOutput, gotOutput)

From porter version test

Testing with an InMemory FileSystem


func TestCreate(t *testing.T) {
	p := NewTestPorter(t)

	err := p.Create()
	require.NoError(t, err)

	// Tests execute against in-memory afero file system
	configFileExists, err := p.FileSystem.Exists(config.Name)
	require.NoError(t, err)
	assert.True(t, configFileExists)

From porter create test

It's a bit anticlimatic really

Thank you


Gopher artwork by Ashley McNamara
licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 License