# Routing Subcommands in WaveHouse: switch, Cobra, or Kong?

> Why a hand-written switch still routes WaveHouse's CLI, what a library would actually buy us, and which one gets my vote.

_2026-08-27 · Taite Lee · tags: engineering, golang_

*No generative AI was used at any point during the writing of this blog post. Written and edited by humans in Michigan.*

Go projects like [WaveHouse](https://wavehouse.dev) that ship a binary with any sort of command line interface (CLI) often face the same question: pull in [Cobra](https://github.com/spf13/cobra)? Maybe use [Kong](https://github.com/alecthomas/kong)? Or, do we keep writing the argument handling ourselves? In my experience, the answer usually gets decided on a whim, and without what I’d consider *adequate* justification. I typically see people just stick with whatever option that they’re most familiar with. As long as they don’t run into any problems along the way, it usually works out.

But as we started working on the CLI for WaveHouse, I wanted to pick an implementation method that, you know, made sense! So, I took some time to figure out exactly what our CLI needed to do, what we already have for free, and what each library would change.

Here is a snippet from `cmd/wavehouse/main.go`:

```go
func main() {
    // Subcommand dispatch. `health` self-probes /livez for the distroless
    // Dockerfile HEALTHCHECK; `validate` checks a settings directory without
    // starting the server; `bootstrap` writes a starter one. An unknown command is a usage error
    // and it must never fall through and silently start the server (`wavehouse validat`
    // booting a listener is not a typo anyone wants). The switch only routes;
    // each subcommand owns a stdlib flag.FlagSet, so `wavehouse <command> -h`
    // prints command-specific help and a stray flag or argument is a usage
    // error. If the command surface outgrows this (nested subcommands, shared
    // persistent flags), port the switch to cobra or kong.
    if len(os.Args) > 1 {
        switch os.Args[1] {
        case "health":
            os.Exit(runHealthCheck(os.Args[2:]))
        case "validate":
            os.Exit(runValidate(os.Args[2:]))
        case "bootstrap":
            os.Exit(runBootstrap(os.Args[2:]))
        case "version", "--version", "-v":
            fmt.Printf("wavehouse %s (commit %s, built %s)\n", Version, GitCommit, BuildTime)
            os.Exit(0)
        case "help", "--help", "-h":
            printUsage(os.Stdout)
            os.Exit(0)
        default:
            fmt.Fprintf(os.Stderr, "wavehouse: unknown command %q\n\n", os.Args[1])
            printUsage(os.Stderr)
            os.Exit(2)
        }
    }
    os.Exit(run())
}
```

In the snippet above, running WaveHouse without any arguments will just start the server. Running WaveHouse with a specified subcommand will initiate one of these jobs instead:

```text
wavehouse                 start the server
wavehouse validate [dir]  validate a settings directory
wavehouse bootstrap       write a starter settings directory
wavehouse health          probe /livez (the Docker HEALTHCHECK)
wavehouse version
```

To implement the routing for the subcommands, I referred back to the classic Args parsing I learned in one of my core CS courses. It’s simple: first check if an arg exists after the WaveHouse command. If an arg does exist, check what it reads (note that we have a switch case on `Args[1]` for the index of the subcommand). Depending on what the subcommand arg reads, we can perform different actions. If we reach the default case, we have not identified the subcommand as one of our listed options.

```mermaid
flowchart TD
    accTitle: How main() routes a command line
    accDescr: If there is no argument after wavehouse, run starts the server. Otherwise os.Args[1] is switched on: validate, health and bootstrap each hand os.Args[2:] to their own function, whose FlagSet parses the remaining args; version and help print and exit 0; anything else prints usage and exits 2.

    start([wavehouse ...]):::neutral --> hasArg{Any argument<br/>after wavehouse?}

    hasArg -- no --> serve[run: start the server]:::wh
    hasArg -- yes --> which{What is os.Args#91;1#93;?}

    which -- validate --> validate["runValidate os.Args[2:]"]:::wh
    which -- health --> health["runHealthCheck os.Args[2:]"]:::wh
    which -- bootstrap --> bootstrap["runBootstrap os.Args[2:]"]:::wh
    which -- version / help --> printExit[print and exit 0]:::neutral
    which -- anything else --> unknown[unknown command<br/>print usage, exit 2]:::fail

    validate --> flagset[that subcommand's FlagSet<br/>parses the remaining args]:::infra
    health --> flagset
    bootstrap --> flagset
```
*How `main()` routes a command line. Only `os.Args[1]` is inspected here; everything after it belongs to the subcommand.*

The switch only decides which subcommand runs though, so it doesn’t look at anything past the subcommand index, like possible arguments. Everything after the subcommand is just passed into the subcommand function. Each subcommand has its own `flag.FlagSet`, which is an argument parser for that specific subcommand. The parser defines the subcommand's flag definitions, prints the subcommand's own help menu, rejects flags that aren't recognized, and returns the positional arguments (like validate's `[dir]`).

But none of our subcommands define any flags at this point, so why does this even matter? Because it’s a foundation. In this setup, adding a `--strict` flag in the future only takes one line of code inside our validate function. But for now, FlagSet works in the background, handling help requests and catching typos.

I’m sure it could seem like I’m trying to argue that not using a library is the way to go for WaveHouse, but I can assure you this is not my intention. What I am trying to do is clearly identify our design requirements before evaluating the possible options. Before comparing any of the alternatives, though, there's one requirement that has to come first, because it decides whether an option is even on the table.

> “An unknown command is a usage error — it must never fall through and silently start the server (`wavehouse validat` booting a listener is not a typo anyone wants).” — Comment from our `main.go`

Because WaveHouse’s default action is starting the server, that action holds a lot of responsibility: it binds a port, opens a [ClickHouse](/managed-clickhouse) connection, and starts consuming streams. If we don’t properly recognize our CLI arguments and resort to “boot up WaveHouse,” then that could cause a second instance of WaveHouse to be initialized, causing all sorts of downstream issues. So whatever we decide has to make the default behavior a safe one, not something that could become problematic down the line.

## What makes a CLI library useful

So, what makes a CLI library actually useful? Obviously, it can’t just be a “flag parsing library.” There are three main tasks that it needs to accomplish:

- **Routing:** mapping WaveHouse subcommands to functions
- **Parsing:** turning flags and positional arguments into typed values with error checking
- **Help:** displaying helpful text for the user and keeping it in sync with existing flags

The current implementation in WaveHouse splits these tasks across three different places: the switch statement does the routing, the FlagSet does the parsing, and the help text is something I hand typed as an output based on the “help” argument. Every CLI library bundles these three jobs differently, each for a specific reason.

What are the options for building a CLI layer for this binary?

## Option 1: Routing switch plus stdlib FlagSets

The first option, as discussed, is the manual switch we have for routing and the FlagSets we use for parsing. They’re two separate pieces of code doing two separate jobs, but they only make sense together.

The switch is straightforward, readable, dependency-free, and the default case is safe by construction. This can also scale without nesting. But when two subcommands need to share a flag, we might need to copy the definition or create a new abstraction layer, and that abstraction is something we probably don’t want to write and maintain.

The parsing side is the standard library’s `flag` package with one FlagSet per subcommand. This is separate from the routing stuff. The switch is manual and written by hand, but the parsing is a real parser shipped with Go. We get typed values, a help menu per subcommand, and "flag provided but not defined" errors on typos, without writing any of that ourselves. What we don’t get is any idea of what the subcommands actually entail (the switch provides that). We also miss out on short flag aliases and grouped flags. There’s also no automatic main help menu. I had to type that string by hand in the main file.

There is one important thing to remember. The parser stops looking for flags as soon as it hits a regular word. You need to put all your flags before any positional arguments. Putting the `--strict` flag before the directory path works perfectly. On the other hand, putting the `--strict` flag after the path will fail because the program reads it as just another argument.

## Option 2: Cobra

Cobra is a CLI library commonly used by Go developers. Cobra provides nested subcommands, so `wavehouse settings validate` is just another `AddCommand` call. We can add persistent flags, so a `--settings-dir` defined once on the root command works on every subcommand under it instead of being copied into each FlagSet. Cobra also generates shell completion and docs from the command tree, which is something Kong needs a separate package to do (more on Kong shortly). Here is what our validate subcommand would look like with Cobra:

```go
var validateCmd = &cobra.Command{
      Use:   "validate [dir]",
      Short: "Validate a settings directory without starting the server",
      Args:  cobra.MaximumNArgs(1),
      RunE: func(cmd *cobra.Command, args []string) error {
              dir := os.Getenv("WH_SETTINGS_DIR")
              if len(args) == 1 {
                      dir = args[0]
              }
              return validate(dir)
      },
}

func init() {
      rootCmd.AddCommand(validateCmd)
}
```

The cost of using Cobra is twofold: adding a branch to the dependency tree, and the way the command tree gets built. Each command is a package-level variable in its own file, and an `init()` function registers it with the root at startup. There also isn’t a straightforward way to read the full list of commands, except running grep for `AddCommand`. That part in particular is obviously less favorable than our switch, where every subcommand is a case in one block.

When considering our safety rule, I initially thought Cobra would fail it. I had read that a root command with a Run function treats an unrecognized first argument as a positional and runs anyway, which for us means a typo starts the server. In Cobra, with a root command that has subcommands, `wavehouse validat` returns `unknown command "validat" for "wavehouse"` and even suggests `validate`. The root never runs. The rule here is that a root command with subcommands accepts no positional arguments at all, so the safe behavior is the default for our shape of binary.

## Option 3: Kong

Kong is a declarative library, so the command tree is a struct. Kong also generates the `--help` output from the struct definition itself, so there’s no usage string to keep in sync.

```go
type CLI struct {
      Validate struct {
              Dir string `arg:"" optional:"" env:"WH_SETTINGS_DIR" help:"Settings directory."`
      } `cmd:"" help:"Validate a settings directory without starting the server."`

      Serve struct{} `cmd:"" default:"1" help:"Start the server."`
      // bootstrap and health would follow the same pattern
}

func main() {
      var cli CLI
      ctx := kong.Parse(&cli)
      os.Exit(run(ctx))
}
```

Everything that Kong needs to use lives in struct tags: the backtick strings after each field. This is similar to the `encoding/json` package’s `json:"name"`. Go does nothing with the tag except store it as metadata on the field and Kong reads it at runtime. In the code block above, `arg:""` marks Dir as a positional argument, `optional:""` says it can be omitted, `env:"WH_SETTINGS_DIR"` gives Kong the fallback that our validate function currently does by hand with `os.Getenv`, and `help:"..."` reads the text for the generated help menu.

By using Kong, there would be no global state, unlike Cobra, where the commands are package level variables that `init()` functions register at startup. In Kong, the entire command tree is a single struct type, which means you can declare an instance of it basically anywhere, including the inside of a test function, and parse fake arguments against it without touching anything global or having to run startup code. A downside to how you define the command tree is that a compiler cannot check whether or not the struct tag strings are spelled correctly. The compiler will see `arg:"" optional:""` as a string literal, so a typo in that would compile with nothing flagged until runtime.

Our safety rule has an interesting catch here. Running the bare program needs to start the server. Kong treats every action as a command, so we tag serve with `default:"1"` to make it the default. This raises the fallthrough question again. If a user types `validat` does Kong throw an error or does it run the server and pass the typo as an argument? From testing this, I found that running with no arguments starts the server. Typing the typo returns an error asking if you meant `validate`. This matches the behavior of Cobra for the exact same reason. The server command refuses extra arguments so the typo gets caught safely.

## Two costs that apply to any library

Now that we've considered some CLI layer options, I think there's additional important context for two more contstraints. The first is that `govulncheck` runs in our pre-commit hook, so every module in our dependency graph is something whose version we eventually have to bump, and the timing of that is somewhat out of our control. Adding in a CLI library obviously makes that a slightly larger headache. Second, the CLI library runs before any of our code, which means every time our binary is spun up, that library sits in the path every time. That’s extra code sitting between our code and the user.

## The decision

With all of this in mind, my decision rested on a single assessment: whether the switch I had implemented was reasonably scalable. Fortunately, there's a pretty clear indicator for when changes need to be made: nested subcommands and/or shared persistent flags. How much we value shell completion also plays a part in the decision, but that's more of a quality of life distinction than pure functionality. Something like `wavehouse settings validate` is a totally valid shape that our CLI layer could take, but a simple switch won’t nest it nicely. The moment certain arguments need to work on three different subcommands, copying FlagSet definitions obviously isn't ideal. And one day, hand writing a completion script might be the final straw in someone's development session.

However, none of these are required of us, nor being requested yet. So, **my simple switch statement stays**.

But, when this does become something we need to expand upon, my vote is Kong. In terms of safety, both Cobra and Kong are great alternatives to what I have implemented now. However, I think Kong is a better fit for the shape of our code. WaveHouse prefers configuration that us developers can easily inspect. Our settings directory, for example, gets validated and snapshotted, not changed and configured at runtime, and a command tree that is a struct value fits that same design better than a tree assembled by a bunch of calls to `init()` and the side effects that come with it. Cobra would still work with correct behavior though, so this is driven more by readability/maintainability than functionality. I would just want the command list to live in one place instead of being spread out throughout our codebase.

This debate obviously begs the question: Are changes in subcommands coming to WaveHouse? I won't give away *too* much, but you can probably figure it out if you keep an eye (or a star) on the [GitHub repo](https://github.com/Wave-RF/WaveHouse).

## Our blogs can become outdated quickly due to changes in software

Everything I've shown here is a snapshot of `main.go` as it stands today, and the CLI is one of the parts of WaveHouse that is actively being worked on right now. The subcommand surface is in active development. When the switch I've defended here does get outgrown, it'll happen the way I described with nested subcommands, shared flags, and the introduction of some CLI library. So please treat this post as the reasoning, not the reference.