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:
- Support installing any external CLI tool distributed in zip archives
- Support activating specific versions per project
- 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
- download and installs the tools defined in a spec file (
uninstall
:- clears the entire or partial content of
~/.toolManager/tools
- clears the entire or partial content of
- 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:
- Reproducibility
It locks the exact versions (including transitive dependencies) so that every team member, CI server, or production environment installs the same versions. - 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.