Build an Agent-Friendly CLI Tool in PHP and Run It Anywhere With CPX

Feature image: Build an Agent-Friendly CLI Tool in PHP and Run It Anywhere With CPX

Command-line tools are more popular than ever. They're useful not only for humans, but also for agents: having a command-line tool that returns JSON data is chef's kiss for any clanker.

Today, we're going to learn how to build one and publish it to Packagist. This way, anyone can run it from any directory without having to install global dependencies thanks to CPX:

cpx nicodevs/weather "Tokyo"
{
    "city": "Tokyo, Japan",
    "temperature": 21.4,
    "wind": 12.3
}

Wait, CPX? What's that?

cpx is to Composer what npx is to npm. It stands for Composer Package Executor, and it lets you run the commands of any Composer package without adding it to your project or to your global dependencies.

Let's discover the power of CPX and build our CLI along the way.

Overview

The Problem With Global Packages

Now, I know what you're thinking:

Running Composer packages from any directory is already possible. You just install the package globally!

Yes, composer global require. That's what we've been doing for years and years. However, there are a couple of downsides, which CPX aims to solve.

Dependency Clashes

Composer treats your global installation just as any other project. It has its own composer.json and its own vendor folder, and every tool you install globally gets added to that same dependency set. So all of those tools must agree on a single version of every shared library.

For example, two different globally installed packages might use symfony/console. The moment one requires symfony/console 6 and another one requires 7, there is no version that satisfies both, and Composer gives the following error:

Your requirements could not be resolved to an installable set of packages.

Global Updates

Global packages don't update themselves. The tool you installed two years ago is still the same version, unless you remember to run composer global update. When was the last time you ran that? Yeah, same.

CPX to the Rescue

CPX is different: instead of installing dependencies in a shared global folder, it gives each package its own installation under ~/.cpx. It resolves by itself with its own vendor folder. Two tools can depend on different versions of the same library without ever meeting.

CPX checks for updates to its isolated packages when you run them, at most once per hour. If there's a newer version that matches your constraint, it updates the package and then runs the command.

CPX was created by Liam Hammett in 2024. In July 2026, the Laravel team rewrote it from scratch and released CPX 2.0 as an official Laravel package. Taylor Otwell presented it during his keynote at Laracon US 2026.

Installing CPX

You'll need PHP 8.3 or newer for CPX itself. The tool we'll build later uses a PHP 8.5 feature, so if you plan to follow along, 8.5 is the version to have. Then install CPX with Composer by running the following command:

composer global require cpx/cpx

Wait, composer global? Isn't that the exact thing we just said was a problem? Well, CPX is the one exception. It ships as a self-contained PHAR with its own dependencies bundled inside, so it adds nothing to your global dependency set, and it can't conflict with anything already there.

Make sure Composer's global bin directory is on your PATH. If you're not sure where it is, this command will tell you:

composer global config bin-dir --absolute

That's it! You can now run cpx from anywhere.

Running a Package

Let's start with the simplest possible example. Say you want to run PHP Insights on a project, but it's not installed in the project or globally. With CPX, you can simply do:

cpx nunomaduro/phpinsights

The first time you run it, CPX downloads the package, installs its dependencies in isolation, and runs the tool against your current directory. That can take a few seconds. Subsequent runs reuse that installation, unless an update is due.

The general shape of a CPX command is the package name, then the command, and then its arguments:

cpx <vendor>/<package> <command> [arguments]

For example, running PHP Insights on a single folder in full looks like this. The first part identifies the package, the second one is the binary inside it, and the rest is what you'd normally pass to the tool:

cpx nunomaduro/phpinsights phpinsights analyse ./src

When a package has only one binary, or the binary has the same name as the package, you can leave that second part out. PHP Insights checks both boxes, so the following command would work:

cpx nunomaduro/phpinsights analyse ./src

You can also request a specific version using the same constraint syntax you'd use in composer.json:

cpx nunomaduro/phpinsights:^2.0 analyse ./src

Either way, your project's composer.json doesn't change. If the project already has the tool installed, CPX runs that copy instead.

Creating Aliases

Typing the full package name every single time is a bit tedious. Luckily, CPX allows you to define aliases using the alias command:

cpx alias nunomaduro/phpinsights insights
cpx alias laravel/installer laravel
cpx alias laravel/pint

If you leave out the alias name, CPX uses the package's short name, so that last command creates the pint alias. To remove an alias, run:

cpx unalias laravel

Aliases are saved under ~/.cpx/, next to the installed packages.

Managing Packages

After a while, you'll have a few packages sitting in ~/.cpx. CPX gives you a handful of commands to manage them:

cpx installed       # every package you've run, with the last time you ran it
cpx update          # update all packages within their constraints
cpx update laravel  # or only the ones from a vendor
cpx clean           # remove the packages you haven't used in a while
cpx clean --all     # remove everything

You'll rarely need cpx update, since CPX checks when you run a package periodically. Still, it's handy when you want to refresh everything at once.

To update CPX itself, run composer global update cpx/cpx if you installed it with Composer, or cpx self-update if you're running the standalone PHAR.

Building Our Own CPX CLI Tool

Time to keep the promise from the intro.

We're going to build weather, a tool that takes a city and prints the current weather as JSON, and we'll publish it so that anyone can run it with cpx nicodevs/weather "Tokyo". I know, contrived example! Feel free to replace the logic with whatever you need. The main takeaway here is to know how to structure and publish the package.

Let's start with the simplest version possible. We'll use Open-Meteo, a weather API that's free for non-commercial use and doesn't require an API key for this example. We'll call the forecast endpoint with file_get_contents using a hard-coded city. By default, temperatures are in Celsius and wind speeds are in km/h.

In an empty directory, create weather.php and paste in the following code:

<?php

$url = 'https://api.open-meteo.com/v1/forecast?' . http_build_query([
    'latitude' => 35.6895,
    'longitude' => 139.6917,
    'current' => 'temperature_2m,wind_speed_10m',
]);

$data = json_decode(file_get_contents($url), true);

echo json_encode([
    'city' => 'Tokyo',
    'temperature' => $data['current']['temperature_2m'],
    'wind' => $data['current']['wind_speed_10m'],
], JSON_PRETTY_PRINT) . PHP_EOL;

To execute the code, run php weather.php in your terminal. You should see the following output:

{
    "city": "Tokyo",
    "temperature": 21,
    "wind": 0.4
}

So far, so good! Before we go any further, let's turn this script into an executable. Add a shebang as the very first line of weather.php, before the <?php tag:

#!/usr/bin/env php
<?php

The shebang tells the operating system to run this file with PHP, so it can be executed directly, without typing php in front. Then move it into a bin folder and make it executable. A bin directory commonly holds ready-to-run programs and you might already have one or multiple. If not, create it first:

mkdir bin
mv weather.php bin/weather
chmod +x bin/weather

From now on, we can run our tool as bin/weather. Later on, when we publish it, we'll tell Composer (and CPX) that this file is our executable.

The OpenMeteo Class

Let's encapsulate the calls from the current draft in a proper OpenMeteo class, so it can accept a city parameter. From here on, we'll use PHP 8.5's pipe operator, so make your sure PHP version is 8.5 or newer.

Create src/OpenMeteo.php:

<?php

namespace Nicodevs\Weather;

use RuntimeException;

class OpenMeteo
{
    public function currentConditions(string $city): array
    {
        $place = $this->geocode($city);

        $data = $this->get('https://api.open-meteo.com/v1/forecast', [
            'latitude' => $place['latitude'],
            'longitude' => $place['longitude'],
            'current' => 'temperature_2m,wind_speed_10m',
        ]);

        return [
            'city' => "{$place['name']}, {$place['country']}",
            'temperature' => $data['current']['temperature_2m'],
            'wind' => $data['current']['wind_speed_10m'],
        ];
    }

    private function geocode(string $city): array
    {
        $places = $this->get('https://geocoding-api.open-meteo.com/v1/search', ['name' => $city, 'count' => 1]);

        return $places['results'][0] ?? throw new RuntimeException("Could not find a city called {$city}");
    }

    private function get(string $url, array $query): array
    {
        $context = stream_context_create(['http' => ['timeout' => 10]]);

        return $url . '?' . http_build_query($query)
            |> (fn ($url) => file_get_contents($url, false, $context))
            |> (fn ($json) => json_decode($json, true, flags: JSON_THROW_ON_ERROR));
    }
}

Why do we need a class? Because the binary should stay lean, and the API logic should be testable without going through the command line. Everything that talks to Open-Meteo will live in the OpenMeteo class, and everything that talks to the terminal will live in bin/weather.

Let's break this down:

  • currentConditions is the request we made before, with the coordinates coming from geocode instead of being hard-coded. It's the only public method, because it's the only thing the outside world needs.
  • geocode gets a city string and returns the best match, with the coordinates we need for the forecast endpoint.
  • get does what the first script did but as a chain: builds the URL, fetchs it, and decodes the JSON. That's what PHP 8.5's pipe operator |> is for: it passes the value on its left as the argument of the callable on its right, so the steps read top to bottom instead of inside out.

The request has a ten-second timeout, and invalid JSON will throw an exception. We'll handle any PHP warnings in the CLI entry point below.

To use the class from the script, we'll let Composer autoload it. Create a composer.json file that maps our namespace to the src/ folder:

{
    "autoload": {
        "psr-4": {
            "Nicodevs\\Weather\\": "src/"
        }
    }
}

Then run composer dump-autoload to generate the autoloader.

From now on, any class under the Nicodevs\Weather namespace is found automatically, as long as the binary requires vendor/autoload.php.

Reading Parameters

Our command-line tool needs to receive the city as a parameter and we'll use symfony/console package for that. Run the following in your terminal:

composer require symfony/console

Symfony's console has two building blocks:

  • A Command declares the arguments and options, and holds the code to run.
  • An Application collects commands and routes the input to the right command.

We only have one command, so we'll register it as the default. That way the tool runs as weather Tokyo, with no command name in between. The command itself is one pipe chain: take the city, look it up, encode the result, print it. Here's the new code for bin/weather:

#!/usr/bin/env php
<?php

use Nicodevs\Weather\OpenMeteo;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

require __DIR__ . '/../vendor/autoload.php';

$command = (new Command('weather'))
    ->addArgument('city', InputArgument::REQUIRED, 'City to look up')
    ->setCode(function (InputInterface $input, OutputInterface $output): int {
        $input->getArgument('city')
            |> (new OpenMeteo())->currentConditions(...)
            |> (fn ($weather) => json_encode($weather, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR))
            |> $output->writeln(...);

        return 0;
    });

$app = new Application('weather', '0.1.0');
$app->addCommand($command);
$app->setDefaultCommand('weather', true);
$app->setCatchExceptions(false);

set_error_handler(
    fn ($severity, $message) => throw new ErrorException($message, 0, $severity),
    E_WARNING,
);

try {
    exit($app->run());
} catch (Throwable $e) {
    echo json_encode(['error' => $e->getMessage()], JSON_INVALID_UTF8_SUBSTITUTE) . PHP_EOL;
    exit(1);
}

The last block is the important one. By default, Symfony catches every exception and prints out a nicely formatted text message. That's helpful and readable for humans, but we want our weather results and handled errors as JSON.

So we turn that off with setCatchExceptions(false) and wrap run() in our own try/catch. With this change, every exception from run() ends up in the same place, whether it's from our OpenMeteo class or Symfony complaining about a missing argument. We print it as a JSON error and exit with 1. json_encode handles escaping and JSON_INVALID_UTF8_SUBSTITUTE keeps even malformed input in an error message encodable. That exit code is how a script (or an agent) knows the command failed.

The error handler converts PHP warnings, such as failed requests or missing response fields, into exceptions so they reach the same JSON error handler.

Alright, let's try it:

bin/weather "New York"
{
    "city": "New York, United States",
    "temperature": 16.5,
    "wind": 7.9
}

Great! Let's try with a city that doesn't exist, like Fooville. You can omit the quotes for a single-word city name.

bin/weather Fooville
{"error":"Could not find a city called Fooville"}

Omitting the city altogether:

bin/weather
{"error":"Not enough arguments (missing: \"city\")."}

And passing a non-existent option:

bin/weather "Tokyo" --bogus
{"error":"The \"--bogus\" option does not exist."}

Also, running bin/weather --help shows a help screen listing the argument:

Usage:
  weather <city>

Arguments:
  city                  City to look up

Weather results and handled errors are JSON. Standard CLI flags are exceptions: --help and --version produce text, and --quiet suppresses successful output.

Publishing the Package

Our tool works, but it only exists on our machine.

To publish it, we need three things: a package name, a composer.json entry that tells Composer (and CPX) where our binary is, and an autoloader that works no matter where the package is installed.

Composer JSON

Open the composer.json file and add a name, a description, a type, a license, the PHP version, and the bin key:

{
    "name": "nicodevs/weather", // [tl! highlight]
    "description": "Weather for any city, as JSON.", // [tl! highlight:3]
    "type": "library",
    "license": "MIT",
    "bin": ["bin/weather"], // [tl! highlight]
    "require": {
        "php": "^8.5", // [tl! highlight]
        "symfony/console": "^8.0"
    },
    "autoload": {
        "psr-4": {
            "Nicodevs\\Weather\\": "src/"
        }
    }
}

Two keys matter here:

  • name is what people will type after cpx.
  • bin is what turns a package into a command-line tool. It tells Composer to expose bin/weather as an executable, and it tells CPX which file to run.

Since weather is the only binary and its name matches the package's name, CPX lets us skip the command name. That's why cpx nicodevs/weather "Tokyo" is all it takes.

The Autoloader Path

There's one last detail in bin/weather we need to change. Right now, it requires __DIR__ . '/../vendor/autoload.php', which is correct when you run the tool from its own folder. But once the package is installed as a dependency, it sits in vendor/nicodevs/weather, and the project's autoloader is three levels up. CPX installs packages that way, so the binary has to check both places. Update the bin/weather file with the following:

require file_exists(__DIR__ . '/../vendor/autoload.php')
    ? __DIR__ . '/../vendor/autoload.php'
    : __DIR__ . '/../../../autoload.php';

That's the only change. The package is ready:

weather/
├── bin/
│   └── weather
├── src/
│   └── OpenMeteo.php
├── composer.json
└── vendor/

Trying It With CPX

We haven't published anything yet, but CPX can run a package straight from a local directory. From inside our folder, pass . as the package:

cpx . "Tokyo"
{
    "city": "Tokyo, Japan",
    "temperature": 21.6,
    "wind": 2.1
}

CPX reads the bin entry from composer.json and runs the binary, without copying or caching anything. If you want to run the package from somewhere else, pass the path to the folder instead.

Why do this when bin/weather already works? Because it runs the package the way CPX will run it after publishing: reading composer.json, finding the bin entry, and running the file from there. If something is off in the metadata, this is where you find out, not after the first release.

Packagist

Push the package to a public GitHub repository and tag a first release:

echo "vendor/" > .gitignore
git init -b main && git add . && git commit -m "first release"
git remote add origin git@github.com:nicodevs/weather.git
git push -u origin main
git tag v0.1.0 && git push --tags

Then go to packagist.org/packages/submit, paste the repository URL, and hit Submit.

Packagist will read the composer.json file, pick up the tag, and the package will be live a minute later. If you enable the GitHub integration that Packagist suggests on the package page, future tags show up on their own.

And that's it! You can run the package on any machine with PHP and CPX:

cpx nicodevs/weather "Tokyo"

The first run installs the package under ~/.cpx, in isolation. Subsequent runs reuse that installation, with periodic updates. No composer global require, no dependency clashes, no manual updates to remember.

GitHub Action to Publish New Versions

It would be cool to automatically publish a new version of our package whenever we push to main, including merges.

Since Packagist already watches the repository for new tags, "publishing" really means "tagging". So we need a workflow that finds the latest vX.Y.Z tag, bumps the patch number, and pushes the new tag.

To do this, create a .github/workflows/release.yml file with the following content:

name: Release

on:
  push:
    branches: [main]

permissions:
  contents: write

concurrency:
  group: release
  queue: max

jobs:
  tag:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - name: Tag the next patch version
        run: |
          git fetch origin --tags
          latest=$(git tag --list 'v*' --sort=-v:refname | sed -nE '/^v[0-9]+\.[0-9]+\.[0-9]+$/p' | sed -n '1p')
          latest=${latest:-v0.0.0}
          IFS=. read -r major minor patch <<< "${latest#v}"
          next="v$major.$minor.$((patch + 1))"
          git tag "$next"
          git push origin "$next"
          echo "Tagged $next"

A few details worth knowing:

  • concurrency queues release runs so they tag one release at a time.
  • fetch-depth: 0 includes the full history and tags; git fetch origin --tags refreshes tags before choosing the next version.
  • --sort=-v:refname puts the newest version first, so v1.10.0 sorts before v1.9.0. The filter keeps only vX.Y.Z tags, skipping prereleases and unrelated names.
  • permissions: contents: write lets the workflow push the tag with the built-in GITHUB_TOKEN. No personal token needed.

From now on, pushes to main will trigger a patch release, and Packagist will pick it up within minutes. When you want a minor or major bump, tag it by hand (git tag v0.2.0 && git push --tags) and the workflow continues from there.

Handing It to Our Agent

We kept our tool agent-friendly:

  • Weather results and handled errors are JSON with a stable shape.
  • There's no colored or formatted text to strip.
  • Successful weather requests exit with 0 and handled errors exit with 1. A caller can check success without parsing the output.
  • It's not interactive: the tool never stops to ask a question.

CPX plays along. It detects when it's not running in a terminal (an AI agent, redirected input, or the --no-interaction flag) and switches to non-interactive mode: no progress output, no prompts, only what the tool prints.

If CPX itself can't do its job, say we have a typo in the package name, it answers in JSON as well:

{
    "success": false,
    "errors": ["The package \"nicodevs/whether\" could not be found."],
    "summary": {}
}

The agent gets JSON from both layers, the tool and the runner.

Now we can teach our agent how to use it: describe it in AGENTS.md, in a skill, or just in a prompt. There's nothing to install on the agent's side beyond PHP and CPX, and the first run installs the package on its own.

Whenever the user asks for the weather, run `cpx nicodevs/weather`
followed by the city, for example `cpx nicodevs/weather "Tokyo"`.
The command prints JSON with the city, temperature in Celsius and wind
speed in km/h. If the city isn't found, check its name and try again.
For other errors, report the problem instead of changing the city.

That's the whole integration. No API client, no SDK, no server; one command that the agent can run anywhere PHP is installed.

In Summary

I love CPX! It's versatile, and it makes distributing our packages easy. Installers, CLI tools, API wrappers ... the sky's the limit. There are many features we didn't get to cover, so make sure to check out CPX documentation.

Of course, the weather tool we built today isn't especially useful on its own. Any capable AI agent could call the API directly. But building the perfect weather tool was never the goal of this article. The real goal was to show you a structure you can reuse to build tools for your own projects.

The question is, what would you build? Whatever it is, if you turn it into a CPX-runnable package, let us know.

Until next time!

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.

By submitting this form, you acknowledge our Privacy Notice.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

Thank you!

We appreciate your interest. We will get right back to you.