How to Implement a Decentralised CLI Tool Manager

Overview

It's common for iOS teams to rely on various CLI tools such as SwiftLint, Tuist, and Fastlane. These tools are often installed in different ways. The most common way is to use Homebrew, which is known to lack version pinning and, as Pedro puts it:

Homebrew is not able to install and activate multiple versions of the same tool

I also fundamentally dislike the tap system for installing dependencies from third-party repositories. Although I don't have concrete data, I feel that most development teams profoundly dislike Homebrew when used beyond the simple installation of individual tools from the command line and the brew taps system is cumbersome and bizarre enough to often discourage developers from using it.

Alternatives to manage sets of CLI tools that got traction in the past couple of years are Mint and Mise. As Pedro again says in his article about Mise:

The first and most core feature of Mise is the ability to install and activate dev tools. Note that we say "activate" because, unlike Homebrew, Mise differentiates between installing a tool and making a specific version of it available. 

While beyond the scope of this article, I recommend a great article about installing Swift executables from source with Mise by Natan Rolnik.

In this article I describe a CLI tool manager very similar to what I've implemented for my team. I'll simply call it "ToolManager". The tool is designed to:

  1. Support installing any external CLI tool distributed in zip archives
  2. Support activating specific versions per project
  3. Be decentralised (requiring no registry)

I believe the decentralisation is an interesting aspect and makes the tool reusable in any development environment. Also, differently from the design of mise and mint, ToolManager doesn't build from source and rather relies on pre-built executables.

In the age of GenAI, it's more important than ever to develop critical thinking and learn how to solve problems. For this reason, I won't show the implementation of ToolManager, as it's more important to understand how it's meant to work. The code you'll see in this article supports the overarching design, not the nitty-gritty details of how ToolManager's commands are implemented.

If, by the end of the article, you understand how the system should work and are interested in implementing it (perhaps using GenAI), you should be able to convert the design to code fairly easily—hopefully, without losing the joy of coding.

I myself am considering implementing ToolManager as an open source project later, as I believe it might be very helpful to many teams, just as its incarnation was (and continues to be) for the platform team at JET. There doesn't seem to be an existing tool with the design described in this article.

A different title could have reasonably placed this article in "The easiest X" "series" (1, 2, 3, 4), if I may say so.

Design

The point here is to learn what implementing a tool manager entails. I'll therefore describe the MVP of ToolManager, leaving out details that would make the design too straightforward to implement.

The tool itself is a CLI and it's reasonably implemented in Swift using ArgumentParser like all modern Swift CLI tools are.

In its simplest form, ToolManager exposes 3 commands:

  • install:
    • download and installs the tools defined in a spec file (Toolfile.yml) at ~/.toolManager/tools optionally validating the checksum
    • creates symlinks to the installed versions at $(PWD)/.toolManager/active
  • uninstall:
    • clears the entire or partial content of ~/.toolManager/tools 
    • clears the content of $(PWD)/.toolManager/active
  • version:
    • returns the version of the tool

The install commands allows to specify the location of the spec file using the --spec flag, which defaults to Toolfile.yml in the current directory.

The installation of ToolManager should be done in the most raw way, i.e. via a remote script. It'd be quite laughable to rely on Brew, wouldn't it?

This practice is commonly used by a variety of tools, for example originally by Tuist (before the introduction of Mise) and... you guessed it... by Brew. We'll see below a basic script to achieve so that you could host on something lik AWS S3 with the desired public permissions.

The installation command would be:

curl -Ls 'https://my-bucket.s3.eu-west-1.amazonaws.com/install_toolmanager.sh' | bash

The version of ToolManager must be defined in the .toolmanager-version file in order for the installation script of the repo to work:

echo "1.2.0" > .toolmanager-version

ToolManager manages versions of CLI tools but it's not in the business of managing its own versions. Back in the day, Tuist used to use tuistenv to solve this problem. I simply avoid it and have single version of ToolManager available at /usr/local/bin/ that the installation script overrides with the version defined for the project. The version command is used by the script to decide if a download is needed.

There will be only one version of ToolManager in the system at a given time, and that's absolutely OK.

At this point, it's time to show an example of installation script:

#!/bin/bash
set -euo pipefail

# Fail fast if essential commands are missing.
command -v curl >/dev/null || { echo "curl not found, please install it."; exit 1; }
command -v unzip >/dev/null || { echo "unzip not found, please install it."; exit 1; }

readonly EXEC_NAME="ToolManager"
readonly INSTALL_DIR="/usr/local/bin"
readonly EXEC_PATH="$INSTALL_DIR/$EXEC_NAME"
readonly HOOK_DIR="$HOME/.toolManager"
readonly REQUIRED_VERSION=$(cat .toolmanager-version)

# Exit if the version file is missing or empty.
if [[ -z "$REQUIRED_VERSION" ]]; then
  echo "Error: .toolmanager-version not found or is empty." >&2
  exit 1
fi

# Exit if the tool is already installed and up to date.
if [[ -f "$EXEC_PATH" ]] && [[ "$($EXEC_PATH version)" == "$REQUIRED_VERSION" ]]; then
  echo "$EXEC_NAME version $REQUIRED_VERSION is already installed."
  exit 0
fi

# Determine OS and the corresponding zip filename.
case "$(uname -s)" in
  Darwin) ZIP_FILENAME="$EXEC_NAME-macOS.zip" ;;
  Linux)  ZIP_FILENAME="$EXEC_NAME-Linux.zip" ;;
  *)      echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac

# Download and install in a temporary directory.
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT # Ensure cleanup on script exit.

echo "Downloading $EXEC_NAME ($REQUIRED_VERSION)..."
DOWNLOAD_URL="https://github.com/MyOrg/$EXEC_NAME/releases/download/$REQUIRED_VERSION/$ZIP_FILENAME"
curl -LSsf --output "$TMP_DIR/$ZIP_FILENAME" "$DOWNLOAD_URL"
unzip -o -qq "$TMP_DIR/$ZIP_FILENAME" -d "$TMP_DIR"

# Use sudo only when the install directory is not writable.
SUDO_CMD=""
if [[ ! -w "$INSTALL_DIR" ]]; then
  SUDO_CMD="sudo"
fi

echo "Installing $EXEC_NAME to $INSTALL_DIR..."
$SUDO_CMD mkdir -p "$INSTALL_DIR"
$SUDO_CMD mv "$TMP_DIR/$EXEC_NAME" "$EXEC_PATH"
$SUDO_CMD chmod +x "$EXEC_PATH"

# Download and source the shell hook to complete installation.
echo "Installing shell hook..."
mkdir -p "$HOOK_DIR"
curl -LSsf --output "$HOOK_DIR/shell_hook.sh" "https://my-bucket.s3.eu-west-1.amazonaws.com/shell_hook.sh"
# shellcheck source=/dev/null
source "$HOOK_DIR/shell_hook.sh"

echo "Installation complete."

You might have noticed that:

  • the required version of ToolManager (defined in .toolmanager-version) is downloaded from the release from the corresponding GitHub repository if missing locally. The ToolManager repo should have a GHA workflow in place to build, archive and upload the version.
  • a shell_hook script is downloaded and run to insert the following line in the shell profile: [[ -s "$HOME/.toolManager/shell_hook.sh" ]] && source "$HOME/.toolManager/shell_hook.sh". This allows switching location in the terminal and loading the active tools for the current project.

Showing an example of shell_hook.sh is in order:

#!/bin/bash
# Overrides 'cd' to update PATH when entering a directory with a local tool setup.

# Add the project-specific bin directory to PATH if it exists.
update_tool_path() {
  local tool_bin_dir="$PWD/.toolManager/active"
  if [[ -d "$tool_bin_dir" ]]; then
    export PATH="$tool_bin_dir:$PATH"
  fi
}

# Redefine 'cd' to trigger the path update after changing directories.
cd() {
  builtin cd "$@" || return
  update_tool_path
}

# --- Installation Logic ---
# The following function only runs when this script is sourced by an installer.

install_hook() {
  local rc_file
  case "${SHELL##*/}" in
    bash) rc_file="$HOME/.bashrc" ;;
    zsh)  rc_file="$HOME/.zshrc" ;;
    *)
      echo "Unsupported shell for hook installation: $SHELL" >&2
      return 1
      ;;
  esac

  # The line to add to the shell's startup file.
  local hook_line="[[ -s \"$HOME/.toolManager/shell_hook.sh\" ]] && source \"$HOME/.toolManager/shell_hook.sh\""

  # Add the hook if it's not already present.
  if ! grep -Fxq "$hook_line" "$rc_file" &>/dev/null; then
    printf "\n%s\n" "$hook_line" >> "$rc_file"
    echo "Shell hook installed in $rc_file. Restart your shell to apply changes."
  fi
}

# This check ensures 'install_hook' only runs when sourced, not when executed.
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
  install_hook
fi

Now that we have a working installation of ToolManager, let define our Toolfile.yml in our project folder:

---
tools:
  - name: PackageGenerator
    binaryPath: PackageGenerator
    version: 3.3.0
    zipUrl: https://github.com/justeattakeaway/PackageGenerator/releases/download/3.3.0/PackageGenerator-macOS.zip
  - name: SwiftLint
    binaryPath: swiftlint
    version: 0.57.0
    zipUrl: https://github.com/realm/SwiftLint/releases/download/0.58.2/portable_swiftlint.zip
  - name: ToggleGen
    binaryPath: ToggleGen
    version: 1.0.0
    zipUrl: https://github.com/TogglesPlatform/ToggleGen/releases/download/1.0.0/ToggleGen-macOS-universal-binary.zip
  - name: Tuist
    binaryPath: tuist
    version: 4.48.0
    zipUrl: https://github.com/tuist/tuist/releases/download/4.54.3/tuist.zip
  - name: Sourcery
    binaryPath: bin/sourcery
    version: 2.2.5
    zipUrl: https://github.com/krzysztofzablocki/Sourcery/releases/download/2.2.5/sourcery-2.2.5.zip

The install command of ToolManager loads the Toolfile at the root of the repo and for each defined dependency, performs the following:

  • checks if the version of the dependency already exists on the machine
  • if it doesn’t exist, downloads it, unzips it, and places the binary at ~/.toolManager/tools/ (e.g. ~/.toolManager/tools/PackageGenerator/3.3.0/PackageGenerator)
  • creates a symlink to the binary in the project directory from .toolManager/active (e.g. .toolManager/active/PackageGenerator)

After running ToolManager install (or ToolManager install --spec=Toolfile.yml), ToolManager should produce the following structure

~ tree ~/.toolManager/tools -L 2
├── PackageGenerator
│   └── 3.3.0
├── Sourcery
│   └── 2.2.5
├── SwiftLint
│   └── 0.57.0
├── ToggleGen
│   └── 1.0.0
└── Tuist
    └── 4.48.0

and from the project folder

ls -la .toolManager/active
<redacted> PackageGenerator -> /Users/alberto/.toolManager/tools/PackageGenerator/3.3.0/PackageGenerator
<redacted> Sourcery -> /Users/alberto/.toolManager/tools/Sourcery/2.2.5/Sourcery
<redacted> SwiftLint -> /Users/alberto/.toolManager/tools/SwiftLint/0.57.0/SwiftLint
<redacted> ToggleGen -> /Users/alberto/.toolManager/tools/ToggleGen/1.0.0/ToggleGen
<redacted> Tuist -> /Users/alberto/.toolManager/tools/Tuist/4.48.0/Tuist

Bumping the versions of some tools in the Toolfile, for example SwiftLint and Tuist, and re-running the install command, should result in the following:

~ tree ~/.toolManager/tools -L 2
├── PackageGenerator
│   └── 3.3.0
├── Sourcery
│   └── 2.2.5
├── SwiftLint
│   ├── 0.57.0
│   └── 0.58.2
├── ToggleGen
│   └── 1.0.0
└── Tuist
    ├── 4.48.0
    └── 4.54.3
ls -la .toolManager/active
<redacted> PackageGenerator -> /Users/alberto/.toolManager/tools/PackageGenerator/3.3.0/PackageGenerator
<redacted> Sourcery -> /Users/alberto/.toolManager/tools/Sourcery/2.2.5/Sourcery
<redacted> SwiftLint -> /Users/alberto/.toolManager/tools/SwiftLint/0.58.2/SwiftLint
<redacted> ToggleGen -> /Users/alberto/.toolManager/tools/ToggleGen/1.0.0/ToggleGen
<redacted> Tuist -> /Users/alberto/.toolManager/tools/Tuist/4.54.3/Tuist

CI Setup

On CI, the setup is quite simple. It involves 2 steps:

  • install ToolManager
  • install the tools

The commands can be wrapped in GitHub composite actions:

name: Install ToolManager

runs:
  using: composite
  steps:
    - name: Install ToolManager
      shell: bash
      run: curl -Ls 'https://my-bucket.s3.eu-west-1.amazonaws.com/install_toolmanager.sh' | bash
name: Install tools

inputs:
  spec:
    description: The name of the ToolManager spec file
    required: false
    default: Toolfile.yml

runs:
  using: composite
  steps:
    - name: Install tools
      shell: bash
      run: |
        ToolManager install --spec=${{ inputs.spec }}
        echo "$PWD/.toolManager/active" >> $GITHUB_PATH

simply used in workflows:

- name: Install ToolManager
  uses: ./.github/actions/install-toolmanager

- name: Install tools
  uses: ./.github/actions/install-tools
  with:
    spec: Toolfile.yml

CLI tools conformance

ToolManager can install tools that are made available in zip files, without the need of implementing any particular spec. Depending on the CLI tool, the executable can be at the root of the zip archive or in a subfolder. Sourcery for example places the executable in the bin folder.

- name: Sourcery
  binaryPath: bin/sourcery
  version: 2.2.5
  zipUrl: https://github.com/krzysztofzablocki/Sourcery/releases/download/2.2.5/sourcery-2.2.5.zip

GitHub releases are great to host releases as zip files and that's all we need. Ideally, one should decorate the repositories with appropriate release workflows.

Following is a simple example that builds a macOS binary. It could be extended to also create a Linux binary.

name: Publish Release

on:
  push:
    tags:
      - '*'

env:
  CLI_NAME: my-awesome-cli-tool

permissions:
  contents: write

jobs:
  build-and-archive:
    name: Build and Archive macOS Binary
    
    runs-on: macos-latest
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Xcode
        uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '16.4'

      - name: Build universal binary
        run: swift build -c release --arch arm64 --arch x86_64
      
      - name: Archive the binary
        run: |
          cd .build/apple/Products/Release/
          zip -r "${{ env.CLI_NAME }}-macOS.zip" "${{ env.CLI_NAME }}"
      
      - name: Upload artifact for release
        uses: actions/upload-artifact@v4
        with:
          name: cli-artifact
          path: .build/apple/Products/Release/${{ env.CLI_NAME }}-macOS.zip

  create-release:
    name: Create GitHub Release
    
    needs: [build-and-archive]
    
    runs-on: ubuntu-latest
    
    steps:
      - name: Download CLI artifact
        uses: actions/download-artifact@v4
        with:
          name: cli-artifact

      - name: Create Release and Upload Asset
        uses: softprops/action-gh-release@v2
        with:
          files: "${{ env.CLI_NAME }}-macOS.zip"

A note on version pinning

Dependency management systems tend to use a lock file (like Package.resolved in Swift Package manager, Podfile.lock in the old days of CocoaPods, yarn.lock/package-lock.json in JavaScript, etc.).

The benefits of using a lock file are mainly 2:

  1. Reproducibility
    It locks the exact versions (including transitive dependencies) so that every team member, CI server, or production environment installs the same versions.
  2. Faster installs
    Dependency managers can skip version resolution if a lock file is present, using it directly to fetch the exact versions, improving speed.

We can remove the need for lock files if we pin the versions in the spec (the file defining the tools). If version range operators like the CocoaPods' optimistic operator ~> and the SPM's .upToNextMajor and similar one didn't exist, usages of lock files would lose its utility.

While useful, lock files are generally annoying and can create that odd feeling of seeing unexpected updates in pull requests made by others. ToolManager doesn't use a lock file; instead, it requires teams to pin their tools' versions, which I strongly believe is a good practice.

This approach comes at the cost of teams having to keep an eye out for patch releases and not leaving updates to the machine, which risks pulling in dependencies that don't respect Semantic Versioning (SemVer).

Support for different architectures

This design allows to support different architectures. Some CI workflows might only need a Linux runner to reduce the burden on precious macOS instances. Both macOS and Linux can be supported with individual Toolfile that can be specified when running the install command.

# on macOS
ToolManager install --spec=Toolfile_macOS

# on Linux
ToolManager install --spec=Toolfile_Linux

Conclusion

The design described in this article powers the solution implemented at JET and has served our teams successfully since October 2023. JET has always preferred to implement in-house solutions where possible and sensible, and I can say that moving away from Homebrew was a blessing.

With this design, the work usually done by a package manager and a central spec repository is shifted to individual components that are only required to publish releases in zip archives, ideally via a release workflow.

By decentralising and requiring version pinning, we made ToolManager a simple yet powerful system for managing the installation of CLI tools.