Most Go CLIs start simple.
You add Cobra, create a few commands, put the logic inside RunE, call fmt.Println, read a couple of flags, and ship it.
For a small tool, that is perfectly fine.
But as the CLI grows, this style starts to collapse.
One command needs authentication.
Another command needs config resolution.
Another command needs interactive input.
Another one must support JSON output for automation.
Some commands are used by humans in terminals.
Some are used by CI pipelines.
Some should prompt.
Some must never prompt.
Eventually, every RunE becomes a mini framework.
That is the moment when a CLI stops being "just some commands" and becomes a real application runtime.
Recently, I refactored a Go CLI built with Cobra in exactly this direction. The goal was to move away from ad hoc command logic and toward a more mature foundation inspired by large Go CLIs such as kubectl.
This article walks through the architecture, the trade-offs, and the Go patterns behind that refactor.
This is not a beginner Cobra tutorial. I assume you already know how to create commands, flags, and RunE handlers. The focus here is on long-term maintainability, testability, terminal behavior, and command architecture.
The Original Problem
The CLI originally had a common shape:
cmd := &cobra.Command{
Use: "login",
RunE: func(cmd *cobra.Command, args []string) error {
username, _ := cmd.Flags().GetString("username")
if username == "" {
return fmt.Errorf("username is required")
}
fmt.Println("Logging in...")
session, err := createSession(cmd)
if err != nil {
return err
}
return session.Login(username)
},
}
This looks harmless.
But the problems grow quickly:
fmt.Println("normal output")
fmt.Fprintln(os.Stderr, "warning")
fmt.Scanln(&value)
configPath, _ := cmd.Flags().GetString("config")
session := newSessionFromCommand(cmd)
client := newClientFromCommand(cmd)
Now each command knows too much.
It knows:
- how to read flags
- how to resolve config
- how to create sessions
- where output goes
- how to prompt users
- how to detect missing input
- how to behave in CI
- how to handle terminal vs non-terminal execution
This creates several long-term issues.
First, behavior becomes inconsistent. One command may prompt when input is missing. Another may fail immediately. Another may print prompts to stdout. Another may write errors using fmt.Println.
Second, testing becomes painful. If a command directly reads from os.Stdin and writes to os.Stdout, tests must either patch globals or execute the command as a subprocess.
Third, automation becomes risky. A command that unexpectedly prompts in CI can hang a pipeline. A prompt printed to stdout can corrupt JSON output. A missing required value may fail in one command but trigger interactive input in another.
The refactor was designed to solve these issues at the architectural level.
The Target Shape
The direction was to move the CLI toward this model:
Cobra command
↓
Options struct
↓
Complete(factory, cmd, args)
↓
Validate()
↓
Run()
And to introduce a shared runtime foundation:
Factory
├── IOStreams
├── ConfigPath
└── PromptPolicy
This gives every command the same execution model.
Cobra remains responsible for command registration, flag parsing, and command routing.
The application logic moves into explicit command options.
Runtime concerns like IO, prompting, config path, and interactivity policy are centralized.
That is the essence of a kubectl-style CLI architecture.
Not because it copies kubectl line-for-line, but because it follows the same conceptual direction:
- central runtime factory
- injected IO streams
- command options
- explicit lifecycle
- separation between command wiring and behavior
- testable execution paths
- predictable terminal semantics
1. Factory: Centralizing CLI Runtime Dependencies
The first key abstraction is the Factory.
A simplified version looks like this:
type Factory struct {
IOStreams IOStreams
ConfigPath string
PromptPolicy PromptPolicy
}
The Factory exists to answer one question:
What runtime context does a command need in order to execute?
Without a factory, every command eventually starts doing this:
configPath, _ := cmd.Flags().GetString("config")
client := newClient(configPath)
session := newSession(configPath)
interactive := detectTerminal(os.Stdin, os.Stderr)
That logic gets duplicated, slightly modified, and eventually becomes inconsistent.
With a Factory, commands receive a shared runtime object:
func NewLoginCmd(f *Factory) *cobra.Command {
opts := &LoginOptions{}
cmd := &cobra.Command{
Use: "login",
RunE: func(cmd *cobra.Command, args []string) error {
return runOptions(f, cmd, args, opts)
},
}
return cmd
}
Now the command is no longer responsible for discovering the whole world.
It can ask the factory for what it needs.
This makes the command easier to test and easier to evolve.
The Risk: Factory Can Become a God Object
A Factory is useful, but it has a dangerous failure mode.
It can become a dumping ground.
Bad direction:
type Factory struct {
IOStreams IOStreams
Config *Config
Client *Client
Session *Session
Logger *Logger
Cache *Cache
Auth *AuthService
Search *SearchService
Submit *SubmitService
}
At that point, Factory becomes a service locator.
That is usually a smell.
A better rule:
Factory should own CLI runtime concerns, not business logic.
It is reasonable for Factory to provide access to streams, config path, terminal policy, and construction helpers.
But domain services should still have explicit dependencies.
Prefer:
submitService := submit.NewService(client, config)
over:
submitService := submit.NewService(factory)
The Factory should make command construction easier. It should not hide every dependency behind one giant object.
2. IOStreams: Stop Talking Directly to the Process
One of the most important changes was adding an IOStreams abstraction.
type IOStreams struct {
In io.Reader
Out io.Writer
ErrOut io.Writer
IsTerminalIn bool
IsTerminalOut bool
IsTerminalErrOut bool
}
At first, this looks like a small change.
It is not.
It changes the CLI from being coupled to the current process into something testable and composable.
Why Direct os.Stdout Usage Becomes a Problem
This is easy:
fmt.Println("Login successful")
But it is also global state.
It writes to the real process stdout.
In tests, this is annoying. In commands that support machine-readable output, it is risky. In long-term CLI architecture, it creates inconsistent output behavior.
Instead:
fmt.Fprintln(streams.Out, "Login successful")
Now output can be redirected in tests:
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
in := strings.NewReader("amir\n")
streams := IOStreams{
In: in,
Out: out,
ErrOut: errOut,
}
This makes command tests much simpler.
stdout vs stderr Is Not Cosmetic
A mature CLI treats stdout and stderr differently.
stdout is for the command result.
stderr is for prompts, warnings, diagnostics, and errors.
Why?
Because stdout is often piped.
Example:
mycli search users --output json > users.json
If a prompt is printed to stdout, the output file may become invalid:
Enter username:
{"users":[...]}
That is broken JSON.
The prompt must go to stderr:
fmt.Fprint(streams.ErrOut, "Enter username: ")
Then stdout stays clean:
{"users":[...]}
This is a critical CLI contract.
It matters even more when a CLI is used in scripts, GitHub Actions, Docker containers, or CI pipelines.
Terminal Detection Belongs in the Runtime
The terminal flags are also important:
IsTerminalIn
IsTerminalOut
IsTerminalErrOut
These allow the CLI to distinguish between:
mycli login
and:
echo "token" | mycli login
or:
mycli search test > output.json
In interactive mode, prompting may be acceptable.
In a pipeline, it may be dangerous.
This is where IOStreams and PromptPolicy work together.
3. PromptPolicy: Make Interactivity Explicit
The CLI added a global flag:
--interactive=auto|always|never
This is one of the most important UX decisions in the whole refactor.
The policy means:
auto → prompt only when stdin and stderr are attached to a terminal
always → require interactive prompting; fail clearly if terminal is unavailable
never → never prompt; require all values explicitly
This prevents a very common CLI problem:
A command behaves nicely on a developer laptop but hangs forever in CI.
For example:
mycli login
In a real terminal, it is fine to ask:
Username:
Password:
But inside CI, that same command should not wait for input forever.
With --interactive=never, the behavior becomes deterministic:
mycli login --interactive=never
If required input is missing, the command fails immediately.
That is exactly what automation needs.
Why auto Should Require stdin and stderr
A good auto policy should usually require at least:
streams.IsTerminalIn && streams.IsTerminalErrOut
Why stderr?
Because prompts are written to stderr. If stderr is not a terminal, interactive prompting may not be visible to the user.
Depending on your CLI, you may also consider stdout. But for prompting specifically, stdin and stderr are usually the key streams.
Should the Flag Be Named --interactive?
--interactive=auto|always|never is clear and explicit.
Alternative names could be:
--prompt=auto|always|never
--input-mode=interactive|non-interactive|auto
--non-interactive
A boolean --non-interactive is common, but less expressive.
The tri-state model is more powerful because it allows a user to say:
- "auto-detect"
- "force prompting"
- "never prompt"
For serious CLIs, the tri-state model is often worth it.
4. Prompt Layer: Centralize Human Input
Before the refactor, prompting could easily spread into command files:
fmt.Print("Enter name: ")
fmt.Scanln(&name)
That approach does not scale.
Prompt behavior should be reusable and policy-driven.
The prompt layer supports:
- text prompts
- secret prompts
- select prompts
Conceptually:
type Prompter struct {
Streams IOStreams
Policy PromptPolicy
}
Then command code can do:
name, err := prompter.Text(ctx, "Name")
if err != nil {
return err
}
For secrets:
password, err := prompter.Secret(ctx, "Password")
if err != nil {
return err
}
Secret input should use:
golang.org/x/term
For example:
func readSecret(fd int) ([]byte, error) {
return term.ReadPassword(fd)
}
This is one place where Go CLI design gets tricky.
term.ReadPassword works with file descriptors, not just io.Reader.
That means a clean prompt abstraction may need to separate generic testable prompting from terminal-specific password reading.
A common pattern is to inject the password reader:
type SecretReader interface {
ReadPassword(fd int) ([]byte, error)
}
or use a function field:
type SecretReadFunc func(fd int) ([]byte, error)
That makes secret prompt behavior testable without requiring a real terminal.
Prompting Should Not Leak Into Commands
The command should not decide terminal behavior manually.
Bad:
if term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Print("Username: ")
fmt.Scanln(&username)
}
Better:
username, err = prompt.Text(ctx, "Username")
The prompt layer handles:
- policy
- terminal checks
- stderr output
- input reading
- empty input behavior
- cancellation behavior
- consistent errors
That keeps command code focused on command semantics.
5. Options Pattern: Complete, Validate, Run
The next major change was introducing command options.
The pattern looks like this:
type LoginOptions struct {
Username string
Password string
Streams IOStreams
Client *client.Client
}
func (o *LoginOptions) Complete(f *Factory, cmd *cobra.Command, args []string) error {
username, err := cmd.Flags().GetString("username")
if err != nil {
return err
}
o.Username = username
o.Streams = f.IOStreams
if o.Username == "" {
value, err := f.Prompter().Text("Username")
if err != nil {
return err
}
o.Username = value
}
o.Client = f.NewClient()
return nil
}
func (o *LoginOptions) Validate() error {
if o.Username == "" {
return fmt.Errorf("username is required")
}
return nil
}
func (o *LoginOptions) Run(ctx context.Context) error {
if err := o.Client.Login(ctx, o.Username, o.Password); err != nil {
return err
}
fmt.Fprintln(o.Streams.Out, "Login successful")
return nil
}
And a shared runner:
type Options interface {
Complete(f *Factory, cmd *cobra.Command, args []string) error
Validate() error
Run(ctx context.Context) error
}
func runOptions(f *Factory, cmd *cobra.Command, args []string, opts Options) error {
ctx := cmd.Context()
if err := opts.Complete(f, cmd, args); err != nil {
return err
}
if err := opts.Validate(); err != nil {
return err
}
return opts.Run(ctx)
}
This lifecycle gives commands clear boundaries.
Complete
Complete collects input and dependencies.
It can read:
- args
- flags
- config path
- prompted values
- clients
- sessions
- runtime dependencies
It should not execute major side effects.
Validate
Validate checks semantic correctness.
It should answer:
Do we have enough valid information to run?
Examples:
if o.Username == "" {
return errors.New("username is required")
}
if !validProvider(o.Provider) {
return fmt.Errorf("unsupported provider %q", o.Provider)
}
Run
Run performs side effects.
Examples:
- API requests
- writing config
- creating sessions
- submitting data
- rendering final output
This split makes tests more focused.
You can test Validate without a fake API.
You can test Complete with fake streams.
You can test Run with a fake client.
This is significantly better than testing a huge RunE closure.
6. Cobra Should Wire Commands, Not Own the Application
Cobra is excellent for:
- command tree
- flags
- args
- help text
- shell completion
- command dispatch
But Cobra should not become your application architecture.
A common problem is passing *cobra.Command deep into the application:
func NewSession(cmd *cobra.Command) (*Session, error) {
configPath, _ := cmd.Flags().GetString("config")
// ...
}
This couples your session layer to Cobra.
That is a design smell.
A session package should not know that Cobra exists.
Better:
func NewSession(configPath string) (*Session, error) {
// ...
}
or:
func NewSession(cfg Config) (*Session, error) {
// ...
}
The command layer can use Cobra to resolve the flag, but lower layers should receive plain Go values.
Good dependency direction:
cmd → runtime/config/client/session
Bad dependency direction:
session → cobra
client → cobra
config → cobra
The deeper your business code knows about Cobra, the harder it becomes to test and reuse.
7. Stream-Aware Output and Table Rendering
Another important refactor was changing output rendering to accept a writer.
Bad:
func RenderTable(rows []Row) {
fmt.Println("NAME\tSTATUS")
for _, row := range rows {
fmt.Printf("%s\t%s\n", row.Name, row.Status)
}
}
Better:
func RenderTable(w io.Writer, rows []Row) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tSTATUS")
for _, row := range rows {
fmt.Fprintf(tw, "%s\t%s\n", row.Name, row.Status)
}
return tw.Flush()
}
Then commands can call:
return table.Render(o.Streams.Out, rows)
Now rendering is testable:
var out bytes.Buffer
err := table.Render(&out, rows)
require.NoError(t, err)
assert.Contains(t, out.String(), "NAME")
This also prepares the CLI for future output modes:
mycli search query --output table
mycli search query --output json
mycli search query --output yaml
A mature CLI usually needs a clear output strategy.
For example:
type OutputFormat string
const (
OutputTable OutputFormat = "table"
OutputJSON OutputFormat = "json"
OutputYAML OutputFormat = "yaml"
)
Then a renderer can own output behavior:
type Renderer interface {
Render(w io.Writer, value any) error
}
Do not mix API logic with table formatting.
That separation becomes very valuable when command output grows.
8. Testing Strategy for This Architecture
This architecture enables better tests.
But it also creates new test responsibilities.
Prompt Tests
Prompt tests should cover:
- text prompt writes prompt text to stderr
- text prompt reads from input
- empty value behavior
- select prompt valid selection
- select prompt invalid selection
- secret prompt does not echo input
- prompt disabled by policy
- prompt fails without terminal in
always - prompt skips/fails in
never
Example:
func TestPromptTextWritesToErrOut(t *testing.T) {
in := strings.NewReader("amir\n")
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
streams := IOStreams{
In: in,
Out: out,
ErrOut: errOut,
IsTerminalIn: true,
IsTerminalErrOut: true,
}
p := NewPrompter(streams, PromptAuto)
value, err := p.Text("Username")
require.NoError(t, err)
require.Equal(t, "amir", value)
require.Empty(t, out.String())
require.Contains(t, errOut.String(), "Username")
}
The important assertion is not just that the prompt works.
The important assertion is:
prompt text does not go to stdout.
Non-Interactive Tests
Every command that supports prompting should have tests for non-interactive mode.
Example:
func TestLoginMissingUsernameNonInteractiveFails(t *testing.T) {
streams := IOStreams{
In: strings.NewReader(""),
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
IsTerminalIn: false,
IsTerminalErrOut: false,
}
f := NewFactory(streams, PromptNever)
cmd := NewLoginCmd(f)
cmd.SetArgs([]string{})
err := cmd.Execute()
require.Error(t, err)
require.Contains(t, err.Error(), "username is required")
}
This protects CI behavior.
Golden Tests
For table output, golden tests are very useful.
Example:
testdata/search_table.golden
Then:
got := out.String()
want := readGolden(t, "testdata/search_table.golden")
require.Equal(t, want, got)
Golden tests help prevent accidental output changes.
That matters because CLI output is a user interface.
Integration Tests
You should also have command-level integration tests that execute Cobra commands with fake streams.
Test cases:
-
loginwith flags only -
loginwith prompt -
loginmissing input with--interactive=never -
searchoutput redirected -
plates addwith prompted missing values - SSO provider selection
- config path override
- invalid config path
- stdout/stderr separation
For a CLI, these tests are often more valuable than many small unit tests.
9. Recommended Package Structure
At the beginning, keeping everything under cli/cmd is acceptable.
But as the CLI grows, cmd can become a junk drawer.
A more scalable structure:
cli/
cmd/
root.go
login.go
logout.go
search.go
submit.go
plates.go
my.go
internal/
cli/
runtime/
factory.go
context.go
streams/
streams.go
prompt/
prompt.go
policy.go
secret.go
table/
table.go
renderer.go
options/
runner.go
config/
loader.go
model.go
writer.go
session/
session.go
store.go
client/
client.go
auth.go
auth/
login.go
sso.go
plates/
service.go
search/
service.go
The goal:
cmd = Cobra wiring
internal/cli = CLI runtime and UX infrastructure
internal/config = config ownership
internal/client = API transport
internal/session = session persistence
domain packages = business behavior
This does not need to happen all at once.
A good extraction order:
- Move
IOStreamsintointernal/cli/streams - Move
PromptPolicyand prompt code intointernal/cli/prompt - Move table rendering into
internal/cli/table - Move config/session/client out of
cmd - Move business-heavy command logic into domain packages
Do not over-engineer too early.
But do prevent cmd from becoming the only package in the application.
10. What Will Hurt First as the CLI Grows?
The first pain point will probably be Factory growth.
If every new feature adds another field to Factory, the abstraction will become too broad.
The second pain point will be command option duplication.
If every options struct manually repeats the same config/client/session setup, you will need shared helpers.
For example:
type ClientOptions struct {
ConfigPath string
Client *client.Client
}
func (o *ClientOptions) CompleteClient(f *Factory) error {
o.ConfigPath = f.ConfigPath
o.Client = f.NewClient()
return nil
}
The third pain point will be output consistency.
As soon as users depend on CLI output, changes become breaking changes.
You may eventually need:
- stable table columns
- JSON schema guarantees
--output--quiet--verbose- structured errors
- exit code conventions
The fourth pain point will be prompt semantics.
Some commands may need required prompts. Some may need optional prompts. Some should never prompt even in auto mode.
You may eventually need command-level prompt declarations:
type PromptRequirement int
const (
PromptOptional PromptRequirement = iota
PromptRequired
PromptForbidden
)
But I would not add this until real commands need it.
11. What Not to Over-Engineer Yet
A good architecture is not the same as adding layers everywhere.
I would avoid these too early:
Do not build a full dependency injection framework
Go does not need a DI container here.
Constructor injection and small factories are enough.
Do not create generic abstractions before repetition exists
If only two commands need something, duplication may be acceptable.
Wait until the pattern is obvious.
Do not move every command into a separate package immediately
That can make navigation harder.
Start by extracting infrastructure:
- streams
- prompt
- table
- config
- client
- session
Then extract domain logic when commands become large.
Do not make Factory own all services
Factory should help create runtime dependencies.
It should not become the application.
Do not hide Cobra too aggressively
Cobra is fine in the command layer.
The important part is preventing Cobra from leaking into lower layers.
12. Senior-Level Review of the Architecture
Overall, this is a strong direction.
The combination of:
- Factory
- IOStreams
- PromptPolicy
- prompt layer
- Complete/Validate/Run
- writer-based rendering
is a solid foundation for a larger Go CLI.
The architecture improves:
- testability
- automation safety
- stdout/stderr correctness
- command consistency
- future extensibility
- separation of concerns
It also aligns conceptually with mature CLIs like kubectl.
But the design must be kept disciplined.
The most important rules going forward:
1. Keep Cobra in cmd.
2. Keep stdout clean.
3. Keep prompts on stderr.
4. Keep Factory focused.
5. Keep business logic out of command wiring.
6. Keep command lifecycle consistent.
7. Keep lower packages free from Cobra.
8. Keep tests stream-aware and policy-aware.
If those rules hold, the CLI can grow to dozens of commands without becoming a maintenance problem.
Final Thoughts
A CLI is not just a thin wrapper around functions.
A serious CLI is an interface contract.
It is used by humans, scripts, CI systems, terminals, pipes, and other tools.
That means small decisions matter:
- stdout or stderr?
- prompt or fail?
- terminal or pipe?
- config from where?
- error with what exit behavior?
- table or JSON?
- command-specific logic or shared runtime?
The refactor described here is not just cleanup.
It creates a foundation.
The CLI moves from this:
Each command does everything itself.
to this:
Commands share a runtime, follow a lifecycle, and behave predictably.
That is the difference between a small Cobra app and a maintainable Go CLI platform.
Top comments (0)