Everyone likes command line completions, so much that some even install extra tools just to have them.

But you don’t need to install anything just for completions: Bash, Fish and ZSH all support it out of the box!

In this post I’ll show you how to ship completions for your Go tools using Cobra and GoReleaser.

Cobra

Cobra is a tool that helps you write command line applications. It is used by a lot of big Go projects, such as Kubernetes, Hugo and many others.

You can follow its README on how to get started, and, get this, it enables a completion command by default, and it works for Bash, ZSH, Fish and PowerShell!

You can also change its default behaviors, just follow this guide.

If everything works, you should now be able to run:

your-cli completion --help

The help for each shell shows you how to enable the completions.

GoReleaser

Shipping it this way works already, but users would have to enable completions manually. I think it’s nicer to enable the completions upon installing the package, and since I release my Go projects with GoReleaser, I can leverage it to handle this for me.

First thing we’ll want to do is creating the completion files. I usually do this in a scripts/completions.sh file. It looks like this:

#!/bin/sh
# scripts/completions.sh
set -e
rm -rf completions
mkdir completions
# TODO: replace your-cli with your binary name
for sh in bash zsh fish; do
	go run main.go completion "$sh" >"completions/your-cli.$sh"
done

And then I call it in my .goreleaser.yml global before hooks:

# .goreleaser.yml
# ...
before:
  hooks:
    - ./scripts/completions.sh
# ...

It’s your choice to commit these completion files or not. I usually don’t, so I also add the completions folder to my .gitignore:

chmod +x ./script/completions.sh
echo completions >>.gitignore

Packaging

Now, we need to package all this up. Let’s see how it looks like for each packager option.

Archive

The first step is adding it to the archives, as we’ll use them in Homebrew, for example.

You can do so by adding the files to the archives section of your .goreleaser.yml:

# .goreleaser.yml
# ...
archives:
  - files:
      - README.md
      - LICENSE.md
      - completions/*
# ...

Homebrew Tap

The Homebrew tap uses the archive to install, so we just need to instruct it to copy the files to the right places:

# .goreleaser.yml
# ...
brews:
  # TODO: change your-cli with your binary name.
  - install: |-
      bin.install "your-cli"
      bash_completion.install "completions/your-cli.bash" => "your-cli"
      zsh_completion.install "completions/your-cli.zsh" => "_your-cli"
      fish_completion.install "completions/your-cli.fish"      
# ...

AUR

The Arch User Repository recipes will also use the archive, so, we just need to put them in the right places:

# .goreleaser.yml
# ...
aurs:
  # TODO: change your-cli with your binary name.
  - package: |-
      # bin
      install -Dm755 "./your-cli" "${pkgdir}/usr/bin/your-cli"
      # license
      install -Dm644 "./LICENSE.md" "${pkgdir}/usr/share/licenses/your-cli/LICENSE"
      # completions
      mkdir -p "${pkgdir}/usr/share/bash-completion/completions/"
      mkdir -p "${pkgdir}/usr/share/zsh/site-functions/"
      mkdir -p "${pkgdir}/usr/share/fish/vendor_completions.d/"
      install -Dm644 "./completions/your-cli.bash" "${pkgdir}/usr/share/bash-completion/completions/your-cli"
      install -Dm644 "./completions/your-cli.zsh" "${pkgdir}/usr/share/zsh/site-functions/_your-cli"
      install -Dm644 "./completions/your-cli.fish" "${pkgdir}/usr/share/fish/vendor_completions.d/your-cli.fish"      
# ...

nFPM

Also known as not-FPM — creates .deb, .rpm and .apk files. It does not use the archives, so we copy the files from the root of our project:

# .goreleaser.yml
# ...
nfpms:
  # TODO: change your-cli with your binary name.
  - contents:
      - src: ./completions/your-cli.bash
        dst: /usr/share/bash-completion/completions/your-cli
        file_info:
          mode: 0644
      - src: ./completions/your-cli.fish
        dst: /usr/share/fish/vendor_completions.d/your-cli.fish
        file_info:
          mode: 0644
      - src: ./completions/your-cli.zsh
        dst:  /usr/share/zsh/vendor-completions/_your-cli
        file_info:
          mode: 0644
# ...

Testing

You may now run:

goreleaser releaser --rm-dist --snapshot

And inspecting the archives et al.

They should all have the completions there. When you’re ready, you can tag and release, and users might just install the packages and have completions in their shells automatically!

Bonus: man pages

On Unix-like systems, it is expected that packages provide man pages as well.

We can do this as well, using Cobra, Mango and GoReleaser.

We’re going to use the same approach:

  • a Cobra command that generates the man package
  • we run a script before the release to execute that command
  • add the man pages to the archives
  • install it appropriately in each packager

Let’s start with the man command:

// main.go

import (
  mcobra "github.com/muesli/mango-cobra"
  "github.com/muesli/roff"
)

// ...
rootCmd.AddCommand(&cobra.Command{
  Use:                   "man",
  Short:                 "Generates manpages",
  SilenceUsage:          true,
  DisableFlagsInUseLine: true,
  Hidden:                true,
  Args:                  cobra.NoArgs,
  RunE: func(cmd *cobra.Command, args []string) error {
    manPage, err := mcobra.NewManPage(1, root.cmd.Root())
    if err != nil {
    return err
    }

    _, err = fmt.Fprint(os.Stdout, manPage.Build(roff.NewDocument()))
    return err
  },
})

// ...

Then we can test it:

go mod tidy
go run . man | less

If that works, the next step is to create our script:

#!/bin/sh
# script/manpages.sh
set -e
rm -rf manpages
mkdir manpages
go run . man | gzip -c -9 >manpages/your-cli.1.gz

The main difference here is that we also need to gzip it. As you can see, it is straightforward to do.

Same as before, we add the manpages folder to .gitignore:

chmod +x ./script/manpages.sh
echo manpages >>.gitignore

The, we add it to our before hooks:

# .goreleaser.yml
# ...
before:
  hooks:
    # keep the other lines here
    - ./scripts/manpages.sh
    # keep the other lines here
# ...

And to the archives:

# .goreleaser.yml
# ...
archives:
  - files:
    # keep the other lines here
    - manpages/*
    # keep the other lines here
# ...

And to Homebrew:

# .goreleaser.yml
# ...
brews:
  - install: |-
    # keep the other lines here
    # TODO: replace your-cli with your binary name
    man1.install "manpages/your-cli.1.gz"
    # keep the other lines here    
# ...

And AUR:

# .goreleaser.yml
# ...
aurs:
  - package: |-
    # keep the other lines here
    # TODO: replace your-cli with your binary name
    install -Dm644 "./manpages/your-cli.1.gz" "${pkgdir}/usr/share/man/man1/your-cli.1.gz"
    # keep the other lines here    
# ...

And finally, nFPM:

# .goreleaser.yml
# ...
nfpms:
  - contents:
    # keep the other lines here
    # TODO: replace your-cli with your binary name
    - src: ./manpages/your-cli.1.gz
      dst: /usr/share/man/man1/your-cli.1.gz
      file_info:
        mode: 0644
    # keep the other lines here
# ...

You can now run a test release like before, and everything should work!

And that’s it!

You should now be able to ship your command with both shell completions and man pages, all automatically generated for you — without having to ask your users to install any third party software.

Another advantage of this approach is that you, as a developer, and your users, don’t need to worry about versioning of the completions: the binary ships with its own completions, and on upgrade, completions are upgraded as well.

See you in the next one!


You can also look into how GoReleaser release itself here.