The Airgap Test: Refactoring a Cobra CLI into a Library API

go dev.to

How a single rule — only RunE touches *cobra.Command — turned a CLI codebase into a library that happens to ship with a CLI.

I run a static analysis pass on my Go CLI that I call a contamination scan. It looks for places where the CLI framework has leaked out of the adapter layer and into code that has no business knowing a CLI exists.

A recent run flagged 25 instances across 11 files. That's not a lot of code, but I was experimenting a lot with the existing codebase and my previous attempt at decoupling Cobra from existing business logic was still not complete. The reason I had to tackle the tech debt now was I now have lot of commands and running them in a terminal is not a productive way for me to create small programs that exercises a given behavior and find bugs to fix. Once I write a program using the Stave library, I just use different data to test different scenario, the code remains the same. It is faster to find and fix bugs.

You might ask, that's why we have integration tests and unit tests. The reality is if you are using any LLM to generate code, the bugs are in the tests written by them. Only way I can make sure it is working is to use Stave functionality as a library and reproduce the problems. It is also a way for me to experiment to answer questions that CISO or security engineer will have on their mind. This makes the human judgement decide the correctness and capture the human knowledge in executable form.

The contamination

Three categories showed up:

Cobra objects passed into processing functions (12 instances):

func runValidate(cmd *cobra.Command, args []string) error {
    global := cliflags.GetGlobalFlags(cmd)
    project, _ := cmd.Flags().GetString("project")
    logger := cmdctx.LoggerFromCmd(cmd)
    // ... 80 lines of validation logic
}
Enter fullscreen mode Exit fullscreen mode

Direct flag access inside business logic (5 instances):

if cmd.Flags().Changed("format") {
    // branch on whether the user explicitly set --format
}
Enter fullscreen mode Exit fullscreen mode

Terminal I/O mixed into the work (8 instances):

RunE: func(cmd *cobra.Command, args []string) error {
    // ... compute exemptions ...
    fmt.Fprintf(cmd.OutOrStdout(), "Acknowledged: %s\n", id)
    // ... more compute ...
    fmt.Fprintln(cmd.OutOrStdout(), "Upcoming expirations:")
    for _, e := range upcoming {
        fmt.Fprintf(cmd.OutOrStdout(), "  %s  %s\n", e.ID, e.Expires)
    }
    return json.NewEncoder(cmd.OutOrStdout()).Encode(result)
}
Enter fullscreen mode Exit fullscreen mode

Every one of these reduces to the same violation: code that should be a pure function is reaching back into the CLI framework to get its inputs and deliver its outputs.

Why it matters

The code worked. Tests passed. The contamination wasn't causing bugs — it was making certain things impossible.

I couldn't write a Go program that called the validation logic directly without spinning up a fake *cobra.Command to pass in. I couldn't reuse the exemption logic from a future scheduled job without scraping a CLI's stdout. I couldn't unit-test the Changed("format") branch without constructing the entire command tree.

The CLI framework had become load-bearing for code that had nothing to do with CLIs.

The mental model shift

The first instinct is defensive: decontaminate the core. Move things out of cmd/, ban the cobra import below a certain line.

The better framing — and the one that drives the design — is the opposite. The pure code isn't core that needs protection from the CLI. It's a library API that happens to have a CLI as its first caller. The CLI is one frontend. A scheduled job could be another. A test harness is another. An embedding into a larger Go program is another.

This sounds like a small distinction. It changes where the code lives.

If you treat the pure code as "internal stuff the CLI uses," it goes in internal/ and Go's visibility rules prevent anyone else from importing it. That's wrong. If the library is the product, it goes in pkg/, and the import path is part of the contract.

The refactor

One rule: only the RunE closure touches *cobra.Command. Everything below it takes plain inputs and returns data, or writes to an io.Writer passed in as a parameter.

Fix 1: Input structs replace cobra parameters

Per command, define an input struct with everything the work needs:

// pkg/validate/validate.go — the library API
package validate

type Input struct {
    Ctx     context.Context
    Stdout  io.Writer
    Stderr  io.Writer
    Logger  *slog.Logger
    Global  config.GlobalSettings
    Project string
}

func Run(in Input) error {
    // pure logic, no cobra anywhere
}
Enter fullscreen mode Exit fullscreen mode

The closure becomes thin — it translates from CLI-world to library-world and gets out of the way:

// cmd/validate/cmd.go — the adapter
RunE: func(cmd *cobra.Command, args []string) error {
    return validate.Run(validate.Input{
        Ctx:     cmd.Context(),
        Stdout:  cmd.OutOrStdout(),
        Stderr:  cmd.ErrOrStderr(),
        Logger:  cmdctx.LoggerFromCmd(cmd),
        Global:  cliflags.GlobalSettingsFrom(cmd),
        Project: mustStr(cmd.Flags().GetString("project")),
    })
}
Enter fullscreen mode Exit fullscreen mode

Fix 2: The transitive import trap

The first time I did this, I hit an invisible problem. My Input struct had a Global cliflags.GlobalFlags field. cliflags imports cobra. So every file that imported pkg/validate transitively imported cobra. The contamination was gone from the source but still in the dependency graph.

The fix is a hard split. The data type lives in pure-land; the function that builds it from a cobra command lives in adapter-land:

// pkg/config/global.go — pure, importable from anywhere
package config

type GlobalSettings struct {
    ConfigPath string
    Profile    string
    LogLevel   string
    NoColor    bool
}
Enter fullscreen mode Exit fullscreen mode
// cmd/cliflags/global.go — adapter, imports cobra
package cliflags

func GlobalSettingsFrom(cmd *cobra.Command) config.GlobalSettings {
    return config.GlobalSettings{
        ConfigPath: mustStr(cmd.Flags().GetString("config")),
        Profile:    mustStr(cmd.Flags().GetString("profile")),
        LogLevel:   mustStr(cmd.Flags().GetString("log-level")),
        NoColor:    mustBool(cmd.Flags().GetBool("no-color")),
    }
}
Enter fullscreen mode Exit fullscreen mode

Input.Global is typed as config.GlobalSettings. No transitive cobra import. Same trap applies to custom flag types implementing pflag.Value, to tri-state helpers, and to anything in a cmdctx-style helper package — pure types in pkg/, conversion functions in cmd/.

Fix 3: Changed() becomes an explicit type

Flags().Changed("format") is the closure asking cobra "did the user set this, or am I looking at a default?" That's a fair question for the adapter. It's not a question pure code should ask cobra. So resolve it at the boundary:

// pkg/config/optional.go
type Optional[T any] struct {
    Value T
    Set   bool
}
Enter fullscreen mode Exit fullscreen mode
// in the closure
format := config.Optional[string]{
    Value: mustStr(cmd.Flags().GetString("format")),
    Set:   cmd.Flags().Changed("format"),
}
Enter fullscreen mode Exit fullscreen mode

Pure code branches on format.Set, which carries no opinion about how the value got set. Aside: before doing this, check whether you need tri-state behavior. Several Changed() checks exist only because defaults aren't deterministic. If you can fix that, the check disappears entirely.

Fix 4: Output is data, not text

The hardest category. Eight instances of fmt.Fprintf(cmd.OutOrStdout(), ...) scattered through what was supposed to be business logic.

For batched output — return data, render at the boundary:

// pkg/exempt/exempt.go
type Result struct {
    Acknowledged []ExemptionID
    Revoked      []ExemptionID
    Upcoming     []Exemption
    Validation   []ValidationIssue
}

func Run(ctx context.Context, in Input) (Result, error) {
    // pure logic — no fmt, no JSON, no writers
}
Enter fullscreen mode Exit fullscreen mode
// cmd/exempt/render.go
func renderText(w io.Writer, r exempt.Result) error { /* ... */ }
func renderJSON(w io.Writer, r exempt.Result) error { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

The four fmt.Fprintf calls for the upcoming-expirations table become a []Exemption field. The inline json.NewEncoder becomes a renderer choice. The validation output becomes structured []ValidationIssue that any frontend can format however it wants.

For streamed output — pass a reporter interface so progress can flow without buffering:

type Reporter interface {
    Health(report HealthReport)
    QueryRow(row QueryRow)
    QueryDone(stats QueryStats)
}

func Query(ctx context.Context, in Input, r Reporter) error
Enter fullscreen mode Exit fullscreen mode

The CLI implements Reporter with stdout writes. A test implements it by appending to a slice. A future daemon implements it by pushing to a channel. The work doesn't know or care.

Making it stick

The refactor is useless if next month's PR re-introduces a cmd.Flags().Changed(...) call somewhere under pkg/. So golangci-lint enforces the rule mechanically:

linters-settings:
  depguard:
    rules:
      core:
        list-mode: lax
        files:
          - "!**/cmd/**"
          - "!**/cliflags/**"
          - "!**/cmdctx/**"
        deny:
          - pkg: "github.com/spf13/cobra"
            desc: "cobraisaCLIadapter;keepitoutoflibrarycode"
          - pkg: "github.com/spf13/pflag"
            desc: "pflagisaCLIadapter;keepitoutoflibrarycode"
          - pkg: "your.module/cmd/cliflags"
            desc: "cliflagsimportscobratransitively;dependonpkg/configinstead"
          - pkg: "your.module/cmd/cmdctx"
            desc: "cmdctximportscobratransitively;receiveresolvedvalues"
Enter fullscreen mode Exit fullscreen mode

Banning cobra alone isn't enough. The last two deny entries close the side door — without them, library code can re-acquire the cobra dependency by importing an adapter package that imports it.

A second rule blocking os.Stdout and os.Stderr writes outside cmd/ closes the I/O door the same way.

What changed in the design

The diff in the file tree is small. The diff in what's possible is large.

Before, the answer to "can I call this validation logic from a Go program without invoking the CLI?" was "technically yes, by constructing a fake cobra.Command and routing fake flags through it." Now it's import "your.module/pkg/validate" and call validate.Run(input).

Before, the test for "what does the exemption system do when a finding is upcoming-but-not-expired?" had to assert against formatted text from stdout. Now it asserts against Result.Upcoming, which is a []Exemption.

Before, there was a category of feature requests that would have required adding flags, plumbing them through a *cobra.Command, and wiring up new fmt.Fprintf calls. Now those features are method calls against the library, and the CLI optionally surfaces them.

The CLI didn't get smaller. The library got real.

The airgap dividend

There's an alternative design that I rejected: extracting the library and exposing it over HTTP or gRPC, the way Docker exposes a daemon. For an airgapped, single-binary tool, that's the wrong approach. There's no client and no server — just one process. The design is much simpler.

What I have instead is a library API consumed in-process by a CLI. The same Go types are the contract; there's no wire format to version, no schema to keep in sync, no serialization cost, no network surface to harden. A future scheduled job inside the same airgap doesn't shell out to the CLI and parse its output. It imports pkg/validate and calls the function. The binary stays static, the deployment stays one file, the trust boundary stays inside the process.

The contamination scan was nominally about the architecture. The result was an answer to "what is this project?" It's a library. The CLI is how I happened to ship it first. If you are scratching your head and thinking that I could have used testscript for this, I will explain why this is different in an upcoming article.


This refactoring is from a real project Stave, an offline configuration safety evaluator.

Source: dev.to

arrow_back Back to Tutorials