Understanding the Architecture of the aqua CLI: A Modular Go Design

The architecture of the aqua CLI separates entry points, command definitions, and business logic controllers into distinct layers, wiring them together via compile-time dependency injection and shared runtime parameters.

The aqua CLI, developed in the aquaproj/aqua repository, serves as a declarative package manager for CLI tools. Understanding the architecture of the aqua CLI reveals a well-structured Go application that maintains strict boundaries between command-line interface parsing, configuration management, and core business logic through distinct architectural layers.

Architectural Overview of the aqua CLI

The architecture follows a clean separation of concerns across multiple layers:

Layer Responsibility Key Source File
Entry Point Program initialization and delegation cmd/aqua/main.go
Runner Shared parameter creation and command registration pkg/cli/runner.go
Global Arguments Flag definitions applicable to all commands pkg/cli/cliargs/cliargs.go
Command Packages Sub-command definitions (install, list, etc.) pkg/cli/*/command.go
Util.Param Runtime state container (IO, logger, version) pkg/cli/util/util.go
Controllers Business logic implementation pkg/controller/*
Configuration aqua.yaml parsing and validation pkg/config/*
Dependency Injection Wire-generated constructor wiring pkg/controller/wire.go

Entry Point and Command Orchestration

The Main Entry Point (cmd/aqua/main.go)

The program begins in cmd/aqua/main.go, which serves as the minimal bootstrap layer. This file invokes the runner's entry function, immediately delegating control to the orchestration layer. This design keeps the main package clean and focused solely on program startup.

The Runner Layer (pkg/cli/runner.go)

The runner.go file acts as the central orchestrator. It performs three critical functions:

  1. Creates shared runtime parameters via util.Param, encapsulating stdin/stdout handles, the logger, runtime information, and version data
  2. Parses global flags defined in cliargs.GlobalFlags that apply to every sub-command
  3. Registers all sub-commands by instantiating command packages and assembling them into the CLI hierarchy

This layer ensures that every command receives consistent access to IO streams, logging, and configuration without duplicating setup code.

Command Structure and Registration

Global CLI Arguments (pkg/cli/cliargs/cliargs.go)

Global flags that apply across all commands—such as --log-level, --config file path, and tracing options—are defined in pkg/cli/cliargs/cliargs.go. The GlobalFlags struct captures these values, which the runner then injects into every sub-command context. This centralized definition ensures consistent flag behavior and validation across the entire CLI surface.

Sub-command Packages (pkg/cli/*/command.go)

Each sub-command (such as install, root-dir, or list) resides in its own package under pkg/cli/. For example, the install command lives in pkg/cli/install/command.go.

Every command package exports a New function with this signature pattern:

func New(r *util.Param, globalArgs *cliargs.GlobalArgs) *cli.Command

This function:

  • Embeds global arguments into a command-specific Args struct
  • Returns a *cli.Command (from the urfave/cli library) with the Action field set to a method that invokes the controller

This pattern keeps CLI concerns (flag parsing, help text) separate from business logic.

The Controller Layer: Business Logic Implementation

Controller Architecture (pkg/controller/*)

The actual work of each command happens in pkg/controller/*. For example, pkg/controller/install/install.go contains the Install method that downloads binaries, validates checksums, and creates symlinks.

Controllers are instantiated with configuration parameters parsed from flags and environment variables. They operate independently of CLI parsing, receiving only the structured data they need to perform operations. This separation makes the business logic testable without requiring a full CLI context.

Dependency Injection with Wire (pkg/controller/wire.go)

The project uses Google's Wire tool for compile-time dependency injection. The pkg/controller/wire.go file contains Wire provider sets that wire together concrete implementations of interfaces used by controllers.

For example, when a controller needs a downloader or checksum validator, Wire generates the constructor code that supplies the correct implementation. This eliminates manual service wiring while maintaining type safety and making dependencies explicit.

Configuration and Runtime Parameters

Shared Runtime State (pkg/cli/util/util.go)

The util.Param struct defined in pkg/cli/util/util.go serves as the shared runtime context passed to every command. It contains:

  • IO streams: Stdin, Stdout, Stderr
  • Logger: Structured logging interface
  • Runtime: OS and architecture information
  • Version: Build version and commit information

This centralization ensures consistent access to system resources and application metadata across all commands without global variables.

Configuration Parsing (pkg/config)

Configuration handling resides in pkg/config/. Files like pkg/config/reader.go parse aqua.yaml, resolve the root installation directory, handle environment variable overrides, and construct a config.Param struct.

Controllers consume this config.Param to determine which tools to install, where to place them, and which registries to consult. The separation between CLI argument parsing (cliargs) and configuration file parsing (config) allows the tool to handle complex configuration hierarchies independently of command-line flag processing.

Command Lifecycle: How aqua install Works

Understanding the architecture becomes concrete when tracing the install command through the system:

  1. Entry: cmd/aqua/main.go invokes the runner
  2. Global Setup: pkg/cli/runner.go creates util.Param and parses cliargs.GlobalFlags
  3. Command Registration: The runner calls install.New(r, globalArgs) from pkg/cli/install/command.go
  4. Action Execution: The command's Action calls command.action(args), which:
    • Starts profiling via profile.Start
    • Converts arguments to config.Param using util.SetParam
    • Creates the controller via controller.InitializeInstallCommandController
    • Invokes controller.Install to perform downloads and linking
  5. Business Logic: The controller in pkg/controller/install/install.go orchestrates lower-level packages (download, checksum, policy) to complete the installation

This flow illustrates how the architecture maintains strict boundaries: CLI concerns stop at the command package, while business logic remains isolated in controllers.

Summary

The architecture of the aqua CLI demonstrates a clean, modular design that separates concerns across distinct layers:

  • Entry and Orchestration: cmd/aqua/main.go and pkg/cli/runner.go handle startup and command registration
  • Command Definitions: Individual packages in pkg/cli/* define sub-commands using the urfave/cli library
  • Business Logic: Controllers in pkg/controller/* contain implementation details, instantiated via Wire-generated dependency injection
  • Configuration: pkg/config and pkg/cli/util manage runtime parameters and aqua.yaml parsing
  • Global State: util.Param provides consistent IO, logging, and runtime information across all commands

This structure makes the codebase testable, extensible, and maintainable, allowing new commands to be added by implementing the standard pattern in pkg/cli/ and corresponding controllers in pkg/controller/.

Frequently Asked Questions

What is the role of the Runner in the aqua CLI architecture?

The Runner in pkg/cli/runner.go serves as the central orchestrator that initializes shared runtime parameters, parses global flags defined in pkg/cli/cliargs/cliargs.go, and registers all sub-commands. It creates the util.Param struct containing IO streams, logger, and version information, ensuring every command receives consistent runtime context without duplicating setup code.

How does the aqua CLI handle dependency injection?

The project uses Google's Wire tool for compile-time dependency injection, configured in pkg/controller/wire.go. Wire generates constructor code that wires together concrete implementations of interfaces used by controllers, such as downloaders and checksum validators. This approach eliminates manual service wiring while maintaining type safety and making dependencies explicit before compilation.

What is the difference between Command packages and Controllers in aqua?

Command packages located in pkg/cli/* (such as pkg/cli/install/command.go) handle CLI-specific concerns including flag parsing, help text, and argument validation using the urfave/cli library. Controllers located in pkg/controller/* (such as pkg/controller/install/install.go) contain the actual business logic for executing commands, operating independently of CLI parsing and receiving only structured configuration data. This separation makes business logic testable without requiring a full CLI context.

How does the aqua CLI manage global flags across all sub-commands?

Global flags such as --log-level, --config, and tracing options are defined centrally in pkg/cli/cliargs/cliargs.go within the GlobalFlags struct. The Runner in pkg/cli/runner.go parses these flags once during startup and injects them into every sub-command context through the New function pattern. This centralized definition ensures consistent flag behavior, validation, and help text across the entire CLI surface without duplicating flag definitions in individual command packages.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →