Our client has a small problem: it only ever tells you the weather in Honolulu. The URL is a constant, and nothing looks at the arguments we’re given.
const url = "https://wttr.in/honolulu?0A"Lovely place, but let’s let people ask about somewhere else.
Why cobra and not flag #
The standard library has flag, and for a single argument you could argue it’s enough. I’m reaching for cobra anyway, for reasons that go beyond this one command.
flag parses -name value and -name=value, and that’s about it.
No --long form as a distinct thing from -short, no combining short flags,
no subcommands, no way to tell it “these two flags are mutually exclusive” or
“this one’s required”.
Anything past that, you write yourself, one CLI at a time.
Cobra gives it to you once:
- Subcommands, nested as deep as you need them.
gwttris one command today, but the moment it grows a second one — say agwttr forecastalongside the current-conditions report — that’s a newcobra.Commandadded to the tree, not a rewrite of how arguments get parsed. - POSIX-style flags for free:
--location=berlin,--location berlin, short forms, and short flags combined the wayps -efcombines them.flagnever learns any of that. - Generated
--helpfor every command and subcommand, built from theUse,Short,Longand flag definitions you already wrote for the code itself — one source of truth instead of a hand-maintained usage string that quietly drifts from what the flags actually do. - Argument validation, like the
cobra.MaximumNArgs(1)below, plus built-in support for required flags and flags that only make sense together. - “Did you mean” suggestions when a subcommand’s misspelled, the same
nicety
gitgives you for a typo’d subcommand. - Shell completions for bash, zsh, fish and PowerShell, generated from the same command tree — again, nothing to hand-write and nothing to keep in sync.
- Man pages, from the same command tree again. The
cobra/docpackage’sGenManTreewalks every command and subcommand and writes a roff page for each, pulling the text straight fromShort/Long/flag descriptions. A tool separate fromgwttritself — a smallgo runscript invoked from the release pipeline, say — calls it once per release; there’s nothing to write by hand, and nothing that can say something the--helpoutput doesn’t already say.
And it’s not a niche choice: kubectl, docker, hugo and most Go CLIs
worth reading are built on cobra, so anyone who’s used one already knows how
ours works.
go get github.com/spf13/cobraBuilding the URL #
Going top-down again, let’s start with the test.
The thing we need is a way to turn a location into a wttr URL, and that belongs
in wttrclient, because that’s the package that knows what wttr.in looks like.
wttrclient_test.go #
func TestURLFor(t *testing.T) {
t.Parallel()
t.Run("builds the url for a location", func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
"https://wttr.in/honolulu?0A",
wttrclient.URLFor("honolulu"),
)
})
t.Run("escapes a location with a space in it", func(t *testing.T) {
t.Parallel()
assert.Equal(
t,
"https://wttr.in/new%20york?0A",
wttrclient.URLFor("new york"),
)
})
}That second case is the one that matters. Plenty of places have a space in the name, and if we paste one straight into a URL we hand the user a broken request and no clue why.
wttrclient.go #
const (
timeout = 5 * time.Second
// baseURL is wttr.in. The ?0A query asks for the current conditions only,
// in ANSI colour rather than the terminal-sniffed default.
baseURL = "https://wttr.in"
query = "?0A"
)
// URLFor returns the wttr.in URL reporting on location. The location is
// escaped, so a place with a space in its name works without the caller
// thinking about it.
func URLFor(location string) string {
return baseURL + "/" + url.PathEscape(location) + query
}url.PathEscape does the work. Nothing exciting, which is the point.
main.go #
Now the command itself.
const defaultLocation = "honolulu"
func main() {
cmd := &cobra.Command{
Use: "gwttr [location]",
Short: "Report the current weather",
Long: "Report the current weather for a location, as coloured ASCII " +
"art from wttr.in.\n\nWith no location, it reports on " +
defaultLocation + ".",
Args: cobra.MaximumNArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
location := defaultLocation
if len(args) == 1 {
location = args[0]
}
return report(cmd.Context(), location, cmd.OutOrStdout())
},
}
// cobra prints the error and this sets the exit code, so nothing below
// needs to call os.Exit itself.
err := cmd.ExecuteContext(context.Background())
if err != nil {
os.Exit(1)
}
}
func report(ctx context.Context, location string, out io.Writer) error {
client := wttrclient.NewWTTRClient(wttrclient.URLFor(location))
return weather.Report(ctx, &client, out)
}A few things worth pointing at.
Args: cobra.MaximumNArgs(1) means cobra rejects a second argument for us and
prints the usage.
We don’t write that check, and we don’t get to forget it.
RunE returns an error rather than calling os.Exit itself.
cobra prints it and we set the exit code once, in one place.
Returning an error from the thing that failed is nicer than exiting from the
middle of it.
cmd.OutOrStdout() instead of os.Stdout means the command writes wherever
it’s told to, which is handy the day you want to test it.
And RunE stays thin.
It picks a location and calls report, which calls into weather, which knows
nothing about cobra.
Keeping the framework at the edge means the code doing the actual work never
learns about it.
Since we default to Honolulu when no location is given, nobody’s existing muscle memory breaks:
$ gwttr
Weather report: honolulu
$ gwttr berlin
Weather report: berlin
$ gwttr "new york"
Weather report: new yorkA version flag, nearly for free #
Here’s a nice one.
goreleaser has been passing -X main.version=<tag> through its default ldflags
this whole time, and we’ve never had a main.version for it to fill in.
The version has been going nowhere.
cobra turns its Version field into a --version flag, so all we need is the
variable:
// version is set at build time by goreleaser's default ldflags, which pass
// -X main.version=<tag>. A build that isn't a release says so rather than
// claiming a version it hasn't got.
var version = "dev"and one more line on the command:
Version: version,$ gwttr --version # a plain go build
gwttr version dev
$ gwttr --version # built the way goreleaser builds it
gwttr version 1.2.3Defaulting to dev matters.
A binary you built yourself shouldn’t claim to be a release, and this way it
doesn’t.
Two linters that needed a word #
If you run a strict golangci-lint config like I do, cobra will upset two of them.
exhaustruct wants every field of a struct literal filled in.
cobra.Command has more than thirty, and it’s designed to be built with the
handful you need, so listing them all would say nothing.
golangci-lint v2 ships exhaustruct as exhaustruct_v5 now, with
ignore-patterns where the old one had exclude, so that’s the spelling to
reach for:
linters:
disable:
- exhaustruct # superseded by exhaustruct_v5
settings:
exhaustruct_v5:
ignore-patterns:
- 'github\.com/spf13/cobra\.Command'wrapcheck flagged weather.Report as an unwrapped error from another
package.
It’s our own package, and it already wraps with %w where the error is raised,
so wrapping it again would only make the message longer:
wrapcheck:
ignore-package-globs:
- github.com/alrayyes/gwttr/*Both are worth configuring rather than working around with //nolint
comments.
Neither weakens the linter for third-party code, which is what they’re actually
there for.
Source code #
This version of the code can be found on Github. The version flag arrived in a follow-up commit.