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:
- Creates shared runtime parameters via
util.Param, encapsulating stdin/stdout handles, the logger, runtime information, and version data - Parses global flags defined in
cliargs.GlobalFlagsthat apply to every sub-command - 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
Argsstruct - Returns a
*cli.Command(from theurfave/clilibrary) with theActionfield 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:
- Entry:
cmd/aqua/main.goinvokes the runner - Global Setup:
pkg/cli/runner.gocreatesutil.Paramand parsescliargs.GlobalFlags - Command Registration: The runner calls
install.New(r, globalArgs)frompkg/cli/install/command.go - Action Execution: The command's
Actioncallscommand.action(args), which:- Starts profiling via
profile.Start - Converts arguments to
config.Paramusingutil.SetParam - Creates the controller via
controller.InitializeInstallCommandController - Invokes
controller.Installto perform downloads and linking
- Starts profiling via
- Business Logic: The controller in
pkg/controller/install/install.goorchestrates 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.goandpkg/cli/runner.gohandle startup and command registration - Command Definitions: Individual packages in
pkg/cli/*define sub-commands using theurfave/clilibrary - Business Logic: Controllers in
pkg/controller/*contain implementation details, instantiated via Wire-generated dependency injection - Configuration:
pkg/configandpkg/cli/utilmanage runtime parameters and aqua.yaml parsing - Global State:
util.Paramprovides 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →