Over the last seven years in IT consulting, I have built more than 50 CLIs for customers and projects. I spent a lot of that time refining workflows around Cobra and Viper, trying to make CLI authoring feel as clean and idiomatic as the rest of the Go around it. I never quite got the workflow I wanted.
There was no single breaking point. The same kinds of friction just kept showing up. I wanted flags and arguments to become ordinary Go values once they reached the application. I got tired of defining the same idea again because help, completion, configuration, or tests needed it. I wanted new capabilities to fit the CLI instead of feeling bolted on. Setup order should only matter when order is actually part of the CLI's meaning.
I also spend a lot of time with Kubernetes and Terraform, so declarative systems were already a natural way for me to think. You declare relationships, validate them, and let the system derive the consequences. After years of trying to improve my CLI workflow, I stopped thinking the remaining friction was another convention I had failed to discover. I thought the underlying authoring model was the constraint.
That line of thinking became Commandment, a declarative CLI framework for Go.
Meet alienctl
I'll use a small fictional CLI called alienctl throughout the article. It has two commands.
$ alienctl scan mars
scanning mars
$ ALIEN_BEAM_STRENGTH=2 alienctl abduct --beam-strength 4 cow
abducting cow with beam strength 4
BeamStrength has a static default of 1. The host environment can contribute 2 through ALIEN_BEAM_STRENGTH. The user can contribute 4 through --beam-strength. The accepted value must also be between 1 and 10.
That one value captures the broader problem I kept running into. The flag spelling can live in parser setup, help and completion can need their own description of it, configuration can separately decide whether the flag beats the environment, and tests can encode the same precedence again. Each piece can be reasonable on its own while the same fact ends up with several owners.
Commandment takes a different approach. Application code declares semantic relationships in ordinary Go. Compile validates them and produces an immutable Application, and later operations read from that application instead of rebuilding the CLI's meaning. That's what I mean by declarative here. It does not mean YAML or struct tags, and it does not replace ordinary Go control flow.
Definition -> Compile -> immutable Application
type abductConfig struct {
Target string
BeamStrength int
}
abductCmd := cli.Command[abductConfig]{
Name: "abduct",
Declare: func(cb *cli.CommandBuilder[abductConfig]) {
cb.Arguments.String(&cb.Config.Target, "target")
beamStrength := cb.Values.Int(&cb.Config.BeamStrength)
cb.Values.Default(beamStrength, 1)
cb.Values.Environment(beamStrength, "ALIEN_BEAM_STRENGTH")
cb.Flags.ValueFor(beamStrength).Long("beam-strength")
cb.Rule.Between(beamStrength, 1, 10)
},
Run: func(_ context.Context, cfg *abductConfig) error {
fmt.Printf(
"abducting %s with beam strength %d\n",
cfg.Target,
cfg.BeamStrength,
)
return nil
},
}
The root also declares a simpler scan command.
type scanConfig struct {
World string
}
var definition = cli.Definition[struct{}]{
Name: "alienctl",
Declare: func(cb *cli.CommandBuilder[struct{}]) {
scanCmd := cli.Command[scanConfig]{
Name: "scan",
Declare: func(cb *cli.CommandBuilder[scanConfig]) {
cb.Arguments.String(&cb.Config.World, "world")
},
Run: func(_ context.Context, cfg *scanConfig) error {
fmt.Printf("scanning %s\n", cfg.World)
return nil
},
}
cb.Subcommands.Command(scanCmd)
cb.Subcommands.Command(abductCmd)
},
}
The separate scanConfig and abductConfig types are deliberate. Each command owns the Go state its handler actually needs instead of contributing fields to one universal root configuration. Commandment's declarations stay ordinary, statically typed Go. The design does not depend on reflection-driven struct or tag decoding.
The process entrypoint is ordinary Go too.
app, err := cli.Compile(definition)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := app.RunFromProcess(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(cli.ExitCode(err))
}
For that invocation, the same destination has three candidate values.
static default -> 1
environment -> 2
flag -> 4
resolved value -> 4
The handler never sees a parser object or a bag of raw strings. It gets an ordinary int. That was one of the things I wanted from the start. The CLI boundary is textual, but application code should work with typed Go values.
int is only the built-in case. An application-defined Go type can use a ValueCodec[T] to define how it crosses the textual CLI boundary. Flags, environment input, defaults, resolution, and inspection can all work with that typed destination instead of inventing separate string representations.
The default, environment variable, flag, and range rule all point at the same typed destination. There is no second BeamStrength definition for configuration or validation.
Help and completion use the compiled application
The compiled application already has enough information to render help.
$ ./alienctl --help
Usage:
alienctl [flags] <command>
Commands:
scan
abduct
help Show help.
completion Generate shell completion source
Flags:
--help Show help.
-h Show help.
The abduct command is available through the intrinsic help action as well.
$ ./alienctl help abduct
Usage:
alienctl abduct [flags] <target>
Arguments:
<target>
Flags:
--beam-strength VALUE, --beam-strength=VALUE
I did not define a second command-and-flag model for help. The compiled application already knows the command, positional argument, and flag spelling, so help can present them without another description of the CLI.
Completion uses the same grammar. With incomplete input ab, the application can determine what can legally come next.
completion, err := app.Complete(ctx, cli.CompletionRequest{
Args: []string{"ab"},
Cursor: cli.CompletionCursor{
Argument: 0,
Byte: 2,
},
})
For this application, the completion result contains abduct.
abduct
The completion request is not tied to a shell. Shell integration adapts the semantic result for a concrete client without defining another command language.
The compiled application still has the grammar, so execution, help, and completion all use it.
Cobra already does this part well
If you use Cobra, this part should be familiar. Cobra already gives Go applications an executable command model with arguments and flags, generated help, and shell completion.
Viper can sit alongside it as a configuration registry, binding command-line flags and environment variables and resolving multiple configuration sources with an explicit precedence order. In the example above, Cobra + Viper can also resolve the effective value to 4.
Commandment is not a feature-for-feature replacement for Viper. Its boundary is the CLI's semantic model. Broader application configuration can stay in ordinary application code. When external configuration affects CLI semantics, it crosses that boundary as typed input instead of becoming another model of the CLI.
What pushed me toward Commandment was the authoring model. After years of writing CLIs, I was tired of carrying setup knowledge across declarations, follow-up mutations, bindings, lifecycle hooks, and initialization timing. Cobra and Viper can be wrapped and organized carefully. I wanted the relationships themselves to be the source of truth.
Commandment validates those relationships during compilation. Order affects behavior only when order is part of the model. Dependencies between semantic values are declared too, so Commandment can validate scope and cycles and derive evaluation order from the dependency graph instead of callback timing.
A typical Cobra + Viper arrangement separates those responsibilities roughly like this.
Cobra / pflag
command grammar
flags
help
completion
Viper
configuration sources
precedence
effective configuration
application code
application-specific policy
versus
Commandment
BeamStrength destination
<- default 1
<- environment ALIEN_BEAM_STRENGTH
<- flag --beam-strength
<- rule Between(1, 10)
Compile
|
v
immutable Application
|
+--------+--------+
| | |
Run Inspect Complete
|
semantic tests
Both approaches can produce 4. With Commandment, the application still knows why 4 won.
Inspecting why the flag won
$ ALIEN_BEAM_STRENGTH=2 alienctl abduct --beam-strength 4 cow
abducting cow with beam strength 4
A terminal-output test can prove that the program printed 4, but it cannot distinguish normal flag precedence from an ignored environment value, a handler overwrite, or a fallback that happened to produce the same integer.
Because Commandment retained the invocation semantics, Inspect can analyze explicit invocation input without executing the selected handler. The clitest package can assert those observations directly.
func TestBeamStrengthFlagWins(t *testing.T) {
t.Parallel()
app := clitest.Compile(t, definition)
beamStrength := clitest.Command("abduct").Flag("--beam-strength").Value()
request := cli.InspectionRequest{
Env: cli.Env{{
Name: "ALIEN_BEAM_STRENGTH",
Value: "2",
}},
Args: []string{"abduct", "--beam-strength", "4", "cow"},
}
clitest.Inspect(t.Context(), app, request).Require(t,
clitest.InspectionReady(),
beamStrength.Text("4"),
beamStrength.ContributedBy(cli.ValueProducerKindFlag),
)
}
That test proves both the effective value and the winning producer.
effective value = 4
winning producer = flag
The default, environment input, and flag are three producers for one semantic destination. Resolution chooses the effective contribution and retains its provenance.
The test does not recreate Commandment's precedence rules. It asks the compiled application what happened.
Parsing does not erase the reason behind the result. Inspect can explain it later without running the handler or inferring semantics from presentation text.
One semantic authority does not mean one representation
One semantic authority does not require one giant data structure or one implementation path for everything.
Help, completion, inspection, diagnostics, schema, reference data, and runtime value state have different consumers and different lifetimes. They deserve different representations.
A semantic fact should still have one authority. Other operations and artifacts can project that fact for their own purpose, but they should not silently redefine it.
Definition
|
| Compile
v
immutable Application
|
+-- Run / RunFromProcess --> execution and intrinsic actions such as help
+-- Complete -----------> semantic completion
+-- Inspect ------------> invocation-specific observation
`-- Schema -------------> detached static artifact
Execution, help, completion, inspection, and schema need different representations, but they should agree on the command and value semantics they share.
That is the design I had been trying to get to. Handlers receive typed Go values, each CLI fact has one owner, and help, completion, inspection, and tests reuse the compiled model. Setup order only matters when the model says it matters.
When would I use this instead of Cobra?
Cobra fits well when the main job is an executable command tree with commands, arguments, flags, dispatch, help, and completion. Pairing it with Viper can also provide configuration through environment variables, defaults, files, and explicit precedence.
Commandment is aimed at CLIs where the command tree is only part of the problem. Typed resolution, provenance, and policy may also need to stay coherent across execution, completion, diagnostics, tooling, and tests.
Cobra can implement the behavior shown in this article. The decision is whether a command tree plus configuration registry is enough semantic authority for the CLI you are building.
For a CLI dominated by routing, flags, help, and completion, Cobra is probably the smaller choice. Commandment starts to earn its extra machinery when you need to ask the compiled CLI why a value resolved the way it did, project the same accepted semantics into tooling, or test those semantics without recreating them.
At that point, I would rather have one source of CLI meaning feeding several views than several reasonable systems that can slowly drift apart.
Commandment RC1
Commandment is now in its RC1 review period and requires Go 1.27.1. I am using this release-candidate period to get the API in front of people building real Go CLIs before I finalize v1.0.0.
The source, examples, and documentation are all in the Commandment repository.
If you try Commandment on an existing Go CLI, I'd especially like to hear what gets simpler, what stays awkward, and anything in the API that you think should change before v1.0.0 is finalized.
Top comments (0)