Upgrade to Pro — share decks privately, control downloads, hide ads and more …

What I Talk About When I Talk About CLI Tool By...

What I Talk About When I Talk About CLI Tool By Golang #gocon

My talk slide at GoCon summer 2015 (http://gocon.connpass.com/event/14063/). How to write good CLI tool by Golang. This is mostly what I'm thinking when writing CLI too by Golang.

So what is *good* CLI tool? I have 7 principles, 1. Do ONE Thing Well, 2. Intuitive UI/UX, 3. Play with Others 4. Helpful, 5. Configurable, 6. Painless Installation, 7. Maintainable. In this talk, I'll explain how to realize these by Golang.

I like Hashicorp way to build CLI tool and often learn & refer it, thanks. !

taichi nakashima

June 21, 2015
Tweet

More Decks by taichi nakashima

Other Decks in Programming

Transcript

  1. $ time heroku apps >/dev/null real 0m3.830s $ time hk

    apps >/dev/null real 0m0.785s e.g., heroku/hk Performance
  2. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  3. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  4. Do ONE thing well Don’t worry, your tool will be

    more and more complex (e.g., tcnksm/ghr) flags.StringVar(&githubAPIOpts.OwnerName, []string{"u", "-username"}, "", "") flags.StringVar(&githubAPIOpts.RepoName, []string{"r", "-repository"}, "", "") flags.StringVar(&githubAPIOpts.Token, []string{"t", "-token"}, "", "") flags.StringVar(&githubAPIOpts.Commitish, []string{"c", "-commitish"}, "", "") flags.BoolVar(&githubAPIOpts.Draft, []string{"-draft"}, false, "") flags.BoolVar(&githubAPIOpts.Prerelease, []string{"-prerelease"}, false, "") flags.IntVar(&ghrOpts.Parallel, []string{"p", "-parallel"}, -1, "") flags.BoolVar(&ghrOpts.Replace, []string{"-replace"}, false, "") flags.BoolVar(&ghrOpts.Delete, []string{"-delete"}, false, "") flags.BoolVar(&stat, []string{"-stat"}, false, “") version := flags.Bool([]string{"v", "-version"}, false, "") debug := flags.Bool([]string{"-debug"}, false, "")
  5. Do ONE thing well `ls` has different 37 flags ls

    [-ABCFGHLOPRSTUW@abcdefghiklmnopqrstuwx1] [file ...]
  6. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  7. Intuitive UI/UX What is CLI UI convention/standard ? # Executable

    + flags + args pattern $ grep —i -C 4 "some string" /tmp # Executable + commands + args pattern $ git --no-pager push -v origin master
  8. Intuitive UI/UX e.g., docker/mflag // Easy to provide short option

    and long option version := mflag.Bool([]string{"v", "-version"}, false, "")
  9. Intuitive UI/UX e.g., mitchellh/cli type Command interface { // Help

    should return long-form help text that includes // the command-line usage Help() string // Synopsis should return a one-line, short synopsis // of the command. Synopsis() string // Run should run the actual command with the given CLI // instance and command-line arguments. Run(args []string) int }
  10. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  11. Play with Others Other systems (softwares) may check error without

    parting the output (e.g., appc/spec) $ actool validate ./image.json $ echo $? 1 # e.g., CI system can detect something wrong and notify
  12. Play with Others We can use os.Exit() // Exit causes

    the current program to exit with // the given status code. // Conventionally, code zero indicates success, // non-zero an error. // The program terminates immediately; deferred functions are // not run. func Exit(code int) { syscall.Exit(code) }
  13. Play with Others When something wrong, call os.Exist with non

    zero number func main() { filePath := args[1] out, err := somethingCool(filepath) if err != nil { fmt.Printf("Error: %s\n", err.Error()) os.Exit(1) } fmt.Printf("Out: %s\n", out) os.Exit(0) }
  14. Play with Others We may need defer for some cleaning

    up func main() { filePath := args[1] out, err := somethingCool(filepath) if err != nil { fmt.Printf("Error: %s\n", err.Error()) os.Exit(1) } defer cleanup() fmt.Printf("Out: %s\n", out) os.Exit(0) }
  15. Play with Others But deferred functions are not run if

    we call os.Exit() // Exit causes the current program to exit with // the given status code. // Conventionally, code zero indicates success, // non-zero an error. // The program terminates immediately; deferred functions are // not run. func Exit(code int) { syscall.Exit(code) }
  16. Play with Others Call os.Exit only on main() and create

    function return exit code func main() { os.Exit(Run(os.Args[1:])) } func Run(args []string) int { filePath := args[0] out, err := somethingCool(filePath) if err != nil { fmt.Printf("Error: %s\n", err.Error()) return 1 } defer cleanup() fmt.Printf("Out: %s\n", out) return 0 }
  17. Play with Others “ Write programs to handle text streams,

    because that is a universal interface. - Doug McIlroy func main() { os.Exit(Run(os.Args[1:])) } func Run(args []string) int { filePath := args[0] out, err := somethingCool(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) return 1 } defer cleanup() fmt.Fprintf(os.Stdout,"Out: %s\n”, out) return 0 }
  18. Play with Others “ Write programs to handle text streams,

    because that is a universal interface. - Doug McIlroy // os.Stdout/os.Stderr is io.Writer // So mostly configurable from other function // flag pacakge flag.SetOutput(os.Stderr) // log package log.SetOutput(os.Stderr)
  19. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  20. Helpful Standard flag package output is … $ ghr -h

    Usage of ghr: -p=0: Parallelization factor -debug=false: Run as debug mode -username=“": Github username -quiet=false: be quieter in some situations -version=false: Print version information and quit
  21. Helpful Write by yourself, more clear usage flag.Usage = func()

    { fmt.Fprint(os.Stderr, helpText) } var helpText = ` Usage: ghr [options] args Command ghr is … Options: -debug Run as Debug mode… `
  22. Helpful Go1.5, help out will be a little bit better

    $ ghr -h Usage of call: -p int Parallelization factor -debug run as debug mode -username string GitHub user name -quiet be quieter in some situations -version Print version information and quit.
  23. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  24. Configurable Central one place will be mostly $HOME directory, and

    you can use user.Current() var defaultCfgName = ".xxxrc" func main() { userInfo, err := user.Current() if err != nil { panic(err) } cfg := filepath.Join(userInfo.HomeDir, defaultCfgName) // … }
  25. Configurable It depends on cgo … // src/os/user/lookup_unix.go u :=

    &User{ Uid: strconv.Itoa(int(pwd.pw_uid)), Gid: strconv.Itoa(int(pwd.pw_gid)), Username: C.GoString(pwd.pw_name), Name: C.GoString(pwd.pw_gecos), HomeDir: C.GoString(pwd.pw_dir), }
  26. Configurable Provide platform agnostic configuration file, use mitchellh/home-dir import "github.com/mitchellh/go-homedir"

    var defaultCfgName = ".xxxrc" func main() { home, err := homedir.Dir() if err != nil { panic(err) } cfg := filepath.Join(home, defaultCfgName) // … }
  27. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  28. Painless Installation Provide Copy & Paste -able one line command

    (e.g., on README.md) $ curl -L https://github.com/docker/machine/releases/download/ v0.3.0/docker-machine_darwin-amd64 \ > /usr/local/bin/docker-machine $ chmod +x /usr/local/bin/docker-machine
  29. Painless Installation Write homebrew formula, it’s easy class Ghr <

    Formula homepage "https://github.com/tcnksm/ghr" version 'v0.4.0' url "https://github.com/tcnksm/ghr/releases/download/v0.4.0/ ghr_v0.4.0_darwin_amd64.zip" sha1 “1aa90dde58c3b15dfd1e2f90cbaa317ee4752855” end
  30. Painless Installation Upload cross-compiled binaries in parallel on Github Releases

    $ ghr v0.1.0 pkg/ --> Uploading: pkg/0.1.0_SHASUMS --> Uploading: pkg/ghr_0.1.0_darwin_386.zip --> Uploading: pkg/ghr_0.1.0_darwin_amd64.zip --> Uploading: pkg/ghr_0.1.0_linux_386.zip --> Uploading: pkg/ghr_0.1.0_linux_amd64.zip --> Uploading: pkg/ghr_0.1.0_windows_386.zip --> Uploading: pkg/ghr_0.1.0_windows_amd64.zip
  31. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  32. Testing is important for fixing bug and adding new feature

    Maintainable func main() { os.Exit(Run(os.Args[1:])) } func Run(args []string) int { filePath := args[0] out, err := somethingCool(filePath) if err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) return 1 } defer cleanup() fmt.Fprintf(os.Stdout,"Out: %s\n”, out) return 0 }
  33. Maintainable func TestRun(t *testing.T) { // test console output and

    status code by argument } Test for console output and status code by arguments
  34. Use io.Writer type cli struct { outWriter, errWriter io.Writer }

    func (c *cli) Run(args []string) int { file := args[0] out, err := somethingCool(file) if err != nil { fmt.Fprintf(c.errWriter, "Error: %s\n", err.Error()) return 1 } defer cleanup() fmt.Fprintf(c.outWriter,"Out: %s\n", out) return 0 } Maintainable
  35. Use io.Writer func main() { cli := &cli{outWriter: os.Stdout, errWriter:

    os.Stderr} os.Exit(cli.Run(os.Args[1:])) } Maintainable
  36. Use bytes.Buffer func TestRun(t *testing.T) { outWriter, errWriter := new(bytes.Buffer),

    new(bytes.Buffer) cli := &cli{outWriter: outWriter, errWriter: errWriter} args := strings.Split("./tool xxx.txt", " ") status := cli.Run(args) if status != 0 { t.Errorf("expected %d to eq %d", status, 0) } expected := "cool" if !strings.Contains(outWriter.String(), expected) { t.Errorf("expected %q to eq %q", outWriter.String(), expected) } } Maintainable
  37. DIFFICULT TO ENCOURAGE USER Once users install it, they may

    not update… Same as iOS or Android Application
  38. Maintainable Encourage user to upgrade to latest version // Simplest

    way is using tags on GitHub githubTag := &latest.GithubTag{ Owner: "tcnksm", Repository: "ghr", } res, _ := latest.Check(githubTag, "0.1.0") if res.Outdated { fmt.Printf("0.1.0 is not latest, you should upgrade to %s”, res.Current) }
  39. Maintainable Encourage user to upgrade to latest version $ ghr

    --version gar version v0.3.1, build aded5ca Your version is out of date! The latest version is v0.4.0
  40. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable
  41. TCNKSM/GCLI Generates the codes and its directory structure you need

    to start building CLI tool right out of the box. (Formerly `cli-init`, re-wrote everything from scratch)
  42. TCNKSM/GCLI Now you can choose your favorite cli framework #

    Example, todo CLI application which has add, list # and delete command with mitchellh/cli framework, $ gcli new -F mitchellh_cli -c add -c list -c delete todo
  43. TCNKSM/GCLI Now you can choose your favorite cli framework $

    tree todo ├── CHANGELOG.md ├── README.md ├── cli.go ├── command │ ├── add.go │ ├── add_test.go │ ├── delete.go │ ├── delete_test.go │ ├── list.go │ ├── list_test.go │ └── meta.go ├── commands.go ├── main.go └── version.go
  44. Do ONE Thing Well Intuitive UI/UX Play with Others Helpful

    Configurable Painless Installation Maintainable