How to Find Tool Installation Paths with aqua which: A Complete Guide

Use aqua which <tool> to resolve the absolute file system path of any tool managed by aqua, or fall back to system PATH for unmanaged binaries.

The aqua which command is essential for debugging, scripting, and understanding how aqua organizes tools in your filesystem. Based on the aquaproj/aqua source code, this guide explains the complete resolution flow from CLI input to absolute path output.

Understanding the aqua which Resolution Architecture

The command operates through three distinct layers that transform a tool name into an absolute filesystem path.

CLI Layer (pkg/cli/which/command.go)

The entry point in pkg/cli/which/command.go handles argument parsing and initializes the controller. The action function sets up the config.Param structure and invokes InitializeWhichCommandController before delegating to the core logic.

func (i *command) action(ctx context.Context, args *Args) error {
    // Sets up param and logger
    ctrl := controller.InitializeWhichCommandController(ctx, i.r.Logger.Logger,
        param, http.DefaultClient, i.r.Runtime)
    
    which, err := ctrl.Which(ctx, logger, param, args.Command)
    // Outputs which.ExePath or which.Package.Version
}

Controller Layer (pkg/controller/which/which.go)

The Which function in pkg/controller/which/which.go implements the search strategy. It scans configuration files in priority order: local aqua.yaml files first, then global configurations, finally falling back to the system PATH.

func (c *Controller) Which(ctx context.Context, logger *slog.Logger,
    param *config.Param, exeName string) (*FindResult, error) {
    
    // 1. Search local config files
    for _, cfgFilePath := range c.configFinder.Finds(param.CWD, "") {
        if fr, _ := c.findExecFile(ctx, logger, param, cfgFilePath, exeName); fr != nil {
            return fr, nil
        }
    }
    
    // 2. Search global config files
    // ...
    
    // 3. Fallback to $PATH
    if exePath := c.lookPath(c.osenv.Getenv("PATH"), exeName); exePath != "" {
        return &FindResult{ExePath: exePath}, nil
    }
    return nil, ErrCommandIsNotFound
}

Domain Model (pkg/config/package.go)

Once a package is identified, Package.ExePath in pkg/config/package.go constructs the absolute path. It computes the package directory using AbsPkgPath (which resolves to $ROOT/pkgs/<type>/github.com/<owner>/<repo>/<version>) and appends the specific file source.

func (p *Package) ExePath(rootDir string, file *registry.File, rt *runtime.Runtime) (string, error) {
    pkgPath, err := p.AbsPkgPath(rootDir, rt)
    fileSrc, err := p.fileSrc(file, rt) // Resolves OS/Arch placeholders
    return filepath.Join(pkgPath, fileSrc), nil
}

How aqua which Searches for Tools

The resolution follows a strict priority order to ensure deterministic behavior.

Local Configuration Scanning

The controller first scans for aqua.yaml files in the current working directory and parent directories using configFinder.Finds. For each configuration found, it calls findExecFile to check if the requested command matches any package defined in that file.

Global Configuration Fallback

If no local configuration contains the tool, the search continues through global configuration files specified in the AQUA_GLOBAL_CONFIG environment variable or default global paths.

System PATH Resolution

When aqua does not manage the tool, the command falls back to lookPath, which searches the system PATH environment variable. This allows aqua which to function as a universal tool locator, returning /bin/ls for system utilities while returning aqua-managed paths for registered tools.

Building the Executable Path

Once a package match is found, the controller calls getExePath to assemble the final filesystem location.

func (c *Controller) getExePath(findResult *FindResult) (string, error) {
    pkg := findResult.Package
    file := findResult.File
    exePath, err := pkg.ExePath(c.rootDir, file, c.runtime)
    if err != nil {
        return "", err
    }
    // Handle registry-defined symlinks
    if file.Link != "" {
        return filepath.Join(filepath.Dir(exePath), file.Link), nil
    }
    return exePath, nil
}

The ExePath method handles different package types (GitHub releases, Go modules, Cargo crates) by computing the appropriate subdirectory structure within the aqua root directory (typically ~/.aqua/pkgs/).

Practical Usage Examples

Use aqua which to verify installation locations and versions in your daily workflow.


# Locate a GitHub release tool

$ aqua which gh
/home/you/.aqua/pkgs/github_release/github.com/cli/cli/v2.4.0/gh

# Display the installed version

$ aqua which --version gh
v2.4.0

# Locate a system tool (falls back to PATH)

$ aqua which ls
/bin/ls

Programmatic Access in Go

You can integrate aqua path resolution into your own Go applications using the controller package.

import (
    "context"
    "log/slog"
    "os"

    "github.com/aquaproj/aqua/v2/pkg/config"
    "github.com/aquaproj/aqua/v2/pkg/controller/which"
)

func main() {
    ctx := context.Background()
    logger := slog.Default()
    
    // Initialize parameter with current working directory
    param := &config.Param{
        CWD: "/path/to/project",
        // Additional configuration as needed
    }
    
    // Initialize controller (simplified; actual initialization requires more dependencies)
    ctrl := which.NewController(/* dependencies */)
    
    result, err := ctrl.Which(ctx, logger, param, "gh")
    if err != nil {
        logger.Error("Tool not found", "error", err)
        os.Exit(1)
    }
    
    logger.Info("Executable path", "path", result.ExePath)
}

Summary

  • aqua which resolves the absolute filesystem path for any tool managed by aqua, falling back to system PATH for unmanaged binaries.
  • The resolution traverses three layers: CLI (pkg/cli/which/command.go), Controller (pkg/controller/which/which.go), and Domain Model (pkg/config/package.go).
  • Search priority is: local aqua.yaml configurations → global configurations → system PATH.
  • The final path is constructed by Package.ExePath, which computes the installation directory based on package type (GitHub release, Go module, etc.).

Frequently Asked Questions

What happens if aqua which cannot find the tool?

If the tool is not defined in any local or global aqua.yaml configuration, the command falls back to searching the system PATH using exec.LookPath. If the binary is not found in PATH, the command returns ErrCommandIsNotFound and exits with an error status.

How does aqua which handle versioned binaries?

When a tool is managed by aqua, aqua which returns the path to the specific version installed in the aqua packages directory (typically ~/.aqua/pkgs/<type>/<source>/<version>/<binary>). If you use the --version flag, the command outputs the version string from the package definition instead of the path.

Can I use aqua which in shell scripts?

Yes, aqua which is designed for script integration. It outputs only the absolute path (or version with --version) to stdout, making it ideal for command substitution. For example: GH_BIN=$(aqua which gh) && $GH_BIN repo view. The command exits with a non-zero status if the tool is not found, enabling standard error handling in scripts.

Where does aqua store installed tools?

Aqua stores tools in a structured directory under the aqua root (default ~/.aqua). The path follows the pattern pkgs/<package_type>/<source>/<version>/<file>. For example, a GitHub release package appears at pkgs/github_release/github.com/owner/repo/v1.0.0/binary. The aqua which command computes this path dynamically based on the package definition in your registry.

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 →