DEV Community

Cover image for Building a kubectl-Style Go CLI: Factory, IOStreams, Prompt Policy, and Command Lifecycles
amir
amir

Posted on

Building a kubectl-Style Go CLI: Factory, IOStreams, Prompt Policy, and Command Lifecycles

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)
    },
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

And to introduce a shared runtime foundation:

Factory
├── IOStreams
├── ConfigPath
└── PromptPolicy
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

over:

submitService := submit.NewService(factory)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If a prompt is printed to stdout, the output file may become invalid:

Enter username:
{"users":[...]}
Enter fullscreen mode Exit fullscreen mode

That is broken JSON.

The prompt must go to stderr:

fmt.Fprint(streams.ErrOut, "Enter username: ")
Enter fullscreen mode Exit fullscreen mode

Then stdout stays clean:

{"users":[...]}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

These allow the CLI to distinguish between:

mycli login
Enter fullscreen mode Exit fullscreen mode

and:

echo "token" | mycli login
Enter fullscreen mode Exit fullscreen mode

or:

mycli search test > output.json
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This prevents a very common CLI problem:

A command behaves nicely on a developer laptop but hangs forever in CI.

For example:

mycli login
Enter fullscreen mode Exit fullscreen mode

In a real terminal, it is fine to ask:

Username:
Password:
Enter fullscreen mode Exit fullscreen mode

But inside CI, that same command should not wait for input forever.

With --interactive=never, the behavior becomes deterministic:

mycli login --interactive=never
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Then command code can do:

name, err := prompter.Text(ctx, "Name")
if err != nil {
    return err
}
Enter fullscreen mode Exit fullscreen mode

For secrets:

password, err := prompter.Secret(ctx, "Password")
if err != nil {
    return err
}
Enter fullscreen mode Exit fullscreen mode

Secret input should use:

golang.org/x/term
Enter fullscreen mode Exit fullscreen mode

For example:

func readSecret(fd int) ([]byte, error) {
    return term.ReadPassword(fd)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

or use a function field:

type SecretReadFunc func(fd int) ([]byte, error)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Better:

username, err = prompt.Text(ctx, "Username")
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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")
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

or:

func NewSession(cfg Config) (*Session, error) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Bad dependency direction:

session → cobra
client → cobra
config → cobra
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Then commands can call:

return table.Render(o.Streams.Out, rows)
Enter fullscreen mode Exit fullscreen mode

Now rendering is testable:

var out bytes.Buffer

err := table.Render(&out, rows)
require.NoError(t, err)

assert.Contains(t, out.String(), "NAME")
Enter fullscreen mode Exit fullscreen mode

This also prepares the CLI for future output modes:

mycli search query --output table
mycli search query --output json
mycli search query --output yaml
Enter fullscreen mode Exit fullscreen mode

A mature CLI usually needs a clear output strategy.

For example:

type OutputFormat string

const (
    OutputTable OutputFormat = "table"
    OutputJSON  OutputFormat = "json"
    OutputYAML  OutputFormat = "yaml"
)
Enter fullscreen mode Exit fullscreen mode

Then a renderer can own output behavior:

type Renderer interface {
    Render(w io.Writer, value any) error
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

This protects CI behavior.

Golden Tests

For table output, golden tests are very useful.

Example:

testdata/search_table.golden
Enter fullscreen mode Exit fullscreen mode

Then:

got := out.String()
want := readGolden(t, "testdata/search_table.golden")

require.Equal(t, want, got)
Enter fullscreen mode Exit fullscreen mode

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:

  • login with flags only
  • login with prompt
  • login missing input with --interactive=never
  • search output redirected
  • plates add with 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This does not need to happen all at once.

A good extraction order:

  1. Move IOStreams into internal/cli/streams
  2. Move PromptPolicy and prompt code into internal/cli/prompt
  3. Move table rendering into internal/cli/table
  4. Move config/session/client out of cmd
  5. 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
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

to this:

Commands share a runtime, follow a lifecycle, and behave predictably.
Enter fullscreen mode Exit fullscreen mode

That is the difference between a small Cobra app and a maintainable Go CLI platform.

Top comments (0)