Від редакції. Ми вже писали про CPX, коли це був сторонній проєкт. З того часу CPX став офіційним пакетом Laravel: версію 2.0 анонсували на Laracon US 2026. Ця стаття від Tighten показує його в ділі.
Інструменти командного рядка популярні як ніколи. Вони корисні не лише людям, а й агентам: консольна команда, яка повертає дані в JSON, - це саме те, що потрібно будь-якому AI-агенту.
Сьогодні ми навчимося створювати такий інструмент і публікувати його на Packagist. Завдяки CPX будь-хто зможе запустити його з будь-якого каталогу без встановлення глобальних залежностей:
cpx nicodevs/weather "Tokyo"
{
"city": "Tokyo, Japan",
"temperature": 21.4,
"wind": 12.3
}
Стоп, CPX? Що це таке?
cpx для Composer - те саме, що npx для npm. Назва розшифровується як Composer Package Executor: він дає змогу запускати команди будь-якого Composer-пакета, не додаючи його ні до проєкту, ні до глобальних залежностей.
Розберімося, на що здатен CPX, і заразом побудуймо свій CLI-інструмент.
Проблема глобальних пакетів
Знаю, про що ви зараз думаєте:
Запускати Composer-пакети з будь-якого каталогу можна й так. Просто встановіть пакет глобально!
Так, composer global require. Саме так ми й робили багато років. Але в цього способу є кілька недоліків, і CPX покликаний їх усунути.
Конфлікти залежностей
Composer ставиться до глобального встановлення як до будь-якого іншого проєкту. У нього є власний composer.json і власний каталог vendor, і кожен інструмент, який ви встановлюєте глобально, потрапляє в той самий набір залежностей. Отже, усі ці інструменти мають погодитися на одну версію кожної спільної бібліотеки.
Наприклад, два різні глобально встановлені пакети можуть використовувати symfony/console. Щойно один вимагає symfony/console 6, а інший - 7, жодна версія не задовольняє обидва, і Composer видає таку помилку:
Your requirements could not be resolved to an installable set of packages.
Глобальні оновлення
Глобальні пакети не оновлюються самі. Інструмент, який ви встановили два роки тому, досі тієї самої версії, якщо ви не згадали запустити composer global update. Коли ви робили це востаннє? Ага, я теж.
CPX поспішає на допомогу
CPX працює інакше: замість того щоб встановлювати залежності в спільний глобальний каталог, він дає кожному пакету окреме встановлення в ~/.cpx. Кожен пакет розв'язує залежності сам, у власному каталозі vendor. Два інструменти можуть залежати від різних версій однієї бібліотеки й ніколи не перетнутися.
CPX перевіряє оновлення своїх ізольованих пакетів під час запуску, щонайбільше раз на годину. Якщо є новіша версія, яка відповідає вашому обмеженню версії, він оновлює пакет і потім виконує команду.
CPX створив Ліам Гаммет (Liam Hammett) у 2024 році. У липні 2026 року команда Laravel переписала його з нуля й випустила CPX 2.0 як офіційний пакет Laravel. Тейлор Отвелл представив його у своїй доповіді на Laracon US 2026.
Встановлення CPX
Для самого CPX потрібен PHP 8.3 або новіший. Інструмент, який ми збудуємо далі, використовує можливість PHP 8.5, тож якщо плануєте повторювати кроки за статтею, вам знадобиться саме 8.5. Встановіть CPX через Composer такою командою:
composer global require cpx/cpx
Стоп, composer global? Хіба це не та сама річ, яку ми щойно назвали проблемою? Що ж, CPX - єдиний виняток. Він постачається як самодостатній PHAR, усі залежності якого зібрані всередині, тож він нічого не додає до вашого глобального набору залежностей і не може ні з чим там конфліктувати.
Переконайтеся, що глобальний каталог bin Composer є у вашому PATH. Якщо не знаєте, де він, ця команда підкаже:
composer global config bin-dir --absolute
От і все! Тепер cpx можна запускати звідусіль.
Запуск пакета
Почнімо з найпростішого прикладу. Припустімо, ви хочете запустити PHP Insights на проєкті, але його не встановлено ні в проєкті, ні глобально. З CPX достатньо виконати:
cpx nunomaduro/phpinsights
Під час першого запуску CPX завантажує пакет, ізольовано встановлює його залежності й запускає інструмент для поточного каталогу. Це може тривати кілька секунд. Наступні запуски використовують це встановлення повторно, якщо не настав час оновлення.
Загальний вигляд команди CPX - назва пакета, потім команда, потім її аргументи:
cpx <vendor>/<package> <command> [arguments]
Наприклад, запуск PHP Insights для одного каталогу в повній формі виглядає так. Перша частина визначає пакет, друга - виконуваний файл (binary) усередині нього, а решта - те, що ви зазвичай передали б інструменту:
cpx nunomaduro/phpinsights phpinsights analyse ./src
Коли в пакеті лише один виконуваний файл або він має ту саму назву, що й пакет, другу частину можна опустити. PHP Insights відповідає обом умовам, тож спрацює така команда:
cpx nunomaduro/phpinsights analyse ./src
Можна також вказати конкретну версію з тим самим синтаксисом обмежень, що й у composer.json:
cpx nunomaduro/phpinsights:^2.0 analyse ./src
У будь-якому разі composer.json вашого проєкту не змінюється. Якщо інструмент уже встановлено в проєкті, CPX запускає саме цю копію.
Створення псевдонімів
Щоразу набирати повну назву пакета трохи втомлює. На щастя, CPX дає змогу визначати псевдоніми командою alias:
cpx alias nunomaduro/phpinsights insights
cpx alias laravel/installer laravel
cpx alias laravel/pint
Якщо не вказати назву псевдоніма, CPX використовує коротку назву пакета, тож остання команда створює псевдонім pint. Щоб видалити псевдонім, виконайте:
cpx unalias laravel
Псевдоніми зберігаються в ~/.cpx/, поруч зі встановленими пакетами.
Керування пакетами
З часом у ~/.cpx накопичиться кілька пакетів. Для керування ними CPX має кілька команд:
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
Тут cpx installed показує всі пакети, які ви запускали, і коли востаннє; cpx update оновлює всі пакети в межах їхніх обмежень версій (або лише пакети одного вендора); cpx clean видаляє пакети, якими ви давно не користувалися, а cpx clean --all - усе.
cpx update знадобиться рідко, адже CPX періодично перевіряє оновлення, коли ви запускаєте пакет. Проте команда стане в пригоді, коли хочеться оновити все й одразу.
Щоб оновити сам CPX, виконайте composer global update cpx/cpx, якщо встановлювали його через Composer, або cpx self-update, якщо користуєтеся окремим PHAR.
Будуємо власний CLI-інструмент для CPX
Час виконати обіцянку зі вступу.
Ми збудуємо weather - інструмент, який приймає назву міста й виводить поточну погоду в JSON, - і опублікуємо його, щоб будь-хто міг запустити його командою cpx nicodevs/weather "Tokyo". Знаю, приклад штучний! Сміливо замінюйте логіку на ту, яка потрібна вам. Головне тут - зрозуміти, як структурувати й публікувати пакет.
Почнімо з найпростішої версії. Скористаємося Open-Meteo - API погоди, безкоштовним для некомерційного використання, якому для нашого прикладу не потрібен API-ключ. Звернемося до ендпоїнта прогнозу через file_get_contents із жорстко заданим містом. За замовчуванням температура повертається в градусах Цельсія, а швидкість вітру - в км/год.
У порожньому каталозі створіть weather.php і вставте такий код:
<?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;
Щоб виконати код, запустіть у терміналі php weather.php. Ви маєте побачити такий результат:
{
"city": "Tokyo",
"temperature": 21,
"wind": 0.4
}
Поки що все добре! Перш ніж іти далі, перетворімо цей скрипт на виконуваний файл. Додайте shebang найпершим рядком weather.php, перед тегом <?php:
#!/usr/bin/env php
<?php
Shebang каже операційній системі виконувати цей файл через PHP, тож його можна запускати напряму, не пишучи php попереду. Потім перенесіть файл у каталог bin і зробіть його виконуваним. Каталог bin зазвичай містить готові до запуску програми, і у вас він, можливо, уже є. Якщо ні, спершу створіть його:
mkdir bin
mv weather.php bin/weather
chmod +x bin/weather
Відтепер інструмент запускається як bin/weather. Пізніше, коли публікуватимемо його, ми скажемо Composer (і CPX), що цей файл - наш виконуваний файл.
Клас OpenMeteo
Інкапсулюймо виклики з поточної чернетки в повноцінний клас OpenMeteo, щоб він міг приймати місто як параметр. Далі ми використовуватимемо оператор конвеєра (pipe operator) з PHP 8.5, тож переконайтеся, що у вас PHP 8.5 або новіший.
Створіть 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));
}
}
Навіщо нам клас? Бо виконуваний файл має лишатися легким, а логіку роботи з API має бути можливо тестувати без командного рядка. Усе, що спілкується з Open-Meteo, житиме в класі OpenMeteo, а все, що спілкується з терміналом, - у bin/weather.
Розберімо код:
currentConditions - це той самий запит, що й раніше, тільки координати тепер беруться з geocode, а не задані жорстко. Це єдиний публічний метод, бо зовнішньому світу більше нічого й не треба.
geocode отримує рядок із назвою міста й повертає найкращий збіг із координатами, потрібними для ендпоїнта прогнозу.
get робить те саме, що й перший скрипт, але ланцюжком: будує URL, завантажує його й декодує JSON. Саме для цього в PHP 8.5 з'явився оператор конвеєра |>: він передає значення зліва як аргумент колбеку справа, тож кроки читаються згори донизу, а не зсередини назовні.
Запит має десятисекундний тайм-аут, а некоректний JSON викине виняток. PHP-попередження ми обробимо нижче, у точці входу CLI.
Щоб використати клас у скрипті, доручимо його автозавантаження Composer. Створіть файл composer.json, який зіставляє наш простір імен із каталогом src/:
{
"autoload": {
"psr-4": {
"Nicodevs\\Weather\\": "src/"
}
}
}
Потім виконайте composer dump-autoload, щоб згенерувати автозавантажувач.
Відтепер будь-який клас у просторі імен Nicodevs\Weather знаходиться автоматично, якщо виконуваний файл підключає vendor/autoload.php.
Читання параметрів
Наш інструмент має отримувати місто як параметр, і для цього ми скористаємося пакетом symfony/console. Виконайте в терміналі:
composer require symfony/console
Консоль Symfony складається з двох будівельних блоків:
Command оголошує аргументи й опції та містить код, який треба виконати.
Application збирає команди й спрямовує вхідні дані до потрібної команди.
У нас лише одна команда, тож зареєструємо її як команду за замовчуванням. Так інструмент запускатиметься як weather Tokyo, без назви команди посередині. Сама команда - це один ланцюжок конвеєра: беремо місто, шукаємо його, кодуємо результат, виводимо. Ось новий код 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);
}
Найважливіший тут останній блок. За замовчуванням Symfony перехоплює кожен виняток і виводить гарно відформатоване текстове повідомлення. Для людей це зручно й читабельно, але ми хочемо отримувати і результати, і оброблені помилки в JSON.
Тому ми вимикаємо цю поведінку через setCatchExceptions(false) і обгортаємо run() власним try/catch. Після цієї зміни кожен виняток із run() потрапляє в одне місце - байдуже, чи кинув його наш клас OpenMeteo, чи Symfony скаржиться на відсутній аргумент. Ми виводимо його як JSON-помилку й завершуємо роботу з кодом 1. json_encode бере на себе екранування, а JSON_INVALID_UTF8_SUBSTITUTE дає змогу закодувати повідомлення про помилку навіть із некоректними вхідними даними. Саме за кодом виходу скрипт (чи агент) дізнається, що команда не вдалася.
Обробник помилок перетворює PHP-попередження, як-от невдалі запити чи відсутні поля у відповіді, на винятки, тож вони потрапляють у той самий JSON-обробник помилок.
Що ж, спробуймо:
bin/weather "New York"
{
"city": "New York, United States",
"temperature": 16.5,
"wind": 7.9
}
Чудово! Тепер спробуймо місто, якого не існує, наприклад Fooville. Для назви з одного слова лапки можна опустити.
bin/weather Fooville
{"error":"Could not find a city called Fooville"}
Без міста взагалі:
bin/weather
{"error":"Not enough arguments (missing: \"city\")."}
І з неіснуючою опцією:
bin/weather "Tokyo" --bogus
{"error":"The \"--bogus\" option does not exist."}
Крім того, bin/weather --help показує довідку з переліком аргументів:
Usage:
weather <city>
Arguments:
city City to look up
Результати й оброблені помилки приходять у JSON. Винятком є стандартні прапорці CLI: --help і --version виводять текст, а --quiet приховує вивід у разі успіху.
Публікація пакета
Наш інструмент працює, але існує лише на нашій машині.
Щоб опублікувати його, потрібні три речі: назва пакета, запис у composer.json, який підкаже Composer (і CPX), де лежить виконуваний файл, і автозавантажувач, який працює незалежно від того, куди встановлено пакет.
Composer JSON
Відкрийте composer.json і додайте назву, опис, тип, ліцензію, версію PHP і ключ bin:
{
"name": "nicodevs/weather",
"description": "Weather for any city, as JSON.",
"type": "library",
"license": "MIT",
"bin": ["bin/weather"],
"require": {
"php": "^8.5",
"symfony/console": "^8.0"
},
"autoload": {
"psr-4": {
"Nicodevs\\Weather\\": "src/"
}
}
}
Тут важливі два ключі:
name - це те, що люди писатимуть після cpx.
bin - те, що перетворює пакет на інструмент командного рядка. Він каже Composer відкрити bin/weather як виконуваний файл, а CPX - який файл запускати.
Оскільки weather - єдиний виконуваний файл і його назва збігається з назвою пакета, CPX дозволяє пропустити назву команди. Тому cpx nicodevs/weather "Tokyo" - це все, що потрібно.
Шлях до автозавантажувача
У bin/weather лишилася ще одна деталь, яку треба змінити. Зараз він підключає __DIR__ . '/../vendor/autoload.php', і це правильно, коли інструмент запускається з власного каталогу. Але щойно пакет встановлено як залежність, він лежить у vendor/nicodevs/weather, а автозавантажувач проєкту - на три рівні вище. Саме так CPX встановлює пакети, тож виконуваний файл має перевіряти обидва місця. Оновіть bin/weather так:
require file_exists(__DIR__ . '/../vendor/autoload.php')
? __DIR__ . '/../vendor/autoload.php'
: __DIR__ . '/../../../autoload.php';
Це єдина зміна. Пакет готовий:
weather/
├── bin/
│ └── weather
├── src/
│ └── OpenMeteo.php
├── composer.json
└── vendor/
Перевіряємо з CPX
Ми ще нічого не опублікували, але CPX уміє запускати пакет просто з локального каталогу. Зсередини нашого каталогу передайте . як пакет:
cpx . "Tokyo"
{
"city": "Tokyo, Japan",
"temperature": 21.6,
"wind": 2.1
}
CPX читає запис bin із composer.json і запускає виконуваний файл, нічого не копіюючи й не кешуючи. Щоб запустити пакет з іншого місця, передайте замість крапки шлях до каталогу.
Навіщо це, якщо bin/weather і так працює? Бо так пакет запускається саме тим способом, яким CPX запускатиме його після публікації: читає composer.json, знаходить запис bin і запускає файл звідти. Якщо в метаданих щось не так, ви дізнаєтеся про це тут, а не після першого випуску.
Packagist
Завантажте пакет у публічний репозиторій на GitHub і позначте тегом перший випуск:
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
Потім відкрийте packagist.org/packages/submit, вставте URL репозиторію й натисніть Submit.
Packagist прочитає composer.json, підхопить тег, і за хвилину пакет буде доступний. Якщо ввімкнути інтеграцію з GitHub, яку Packagist пропонує на сторінці пакета, нові теги з'являтимуться самі.
От і все! Пакет можна запускати на будь-якій машині з PHP і CPX:
cpx nicodevs/weather "Tokyo"
Перший запуск ізольовано встановлює пакет у ~/.cpx. Наступні запуски використовують це встановлення з періодичними оновленнями. Жодного composer global require, жодних конфліктів залежностей, жодних ручних оновлень, про які треба пам'ятати.
GitHub Action для публікації нових версій
Було б зручно автоматично публікувати нову версію пакета щоразу, коли ми пушимо в main, включно зі злиттями.
Packagist уже стежить за новими тегами в репозиторії, тож «публікувати» насправді означає «ставити тег». Отже, нам потрібен workflow, який знаходить останній тег vX.Y.Z, збільшує номер патча й пушить новий тег.
Для цього створіть файл .github/workflows/release.yml з таким вмістом:
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"
Кілька деталей, які варто знати:
concurrency ставить запуски випуску в чергу, щоб вони ставили теги по одному.
fetch-depth: 0 завантажує всю історію й теги; git fetch origin --tags оновлює теги перед вибором наступної версії.
--sort=-v:refname ставить найновішу версію першою, тож v1.10.0 іде перед v1.9.0. Фільтр лишає тільки теги vX.Y.Z, пропускаючи передрелізні версії й сторонні назви.
permissions: contents: write дозволяє workflow пушити тег із вбудованим GITHUB_TOKEN. Особистий токен не потрібен.
Відтепер кожен пуш у main запускатиме патч-випуск, і Packagist підхопить його за кілька хвилин. Коли знадобиться мінорне чи мажорне підвищення версії, поставте тег вручну (git tag v0.2.0 && git push --tags), і workflow продовжить уже від нього.
Передаємо інструмент агенту
Ми зробили інструмент зручним для агентів:
- Результати й оброблені помилки приходять у JSON зі стабільною структурою.
- Немає кольорового чи відформатованого тексту, який довелося б вичищати.
- Успішні запити погоди завершуються з кодом
0, а оброблені помилки - з кодом 1. Той, хто викликає команду, може перевірити успіх, не розбираючи вивід.
- Інструмент не інтерактивний: він ніколи не зупиняється, щоб щось запитати.
CPX теж підіграє. Він визначає, що запущений не в терміналі (AI-агентом, із перенаправленим вводом або з прапорцем --no-interaction), і переходить у неінтерактивний режим: жодного виводу прогресу, жодних запитань, тільки те, що виводить сам інструмент.
Якщо ж CPX не може виконати свою роботу - скажімо, у назві пакета друкарська помилка, - він теж відповідає в JSON:
{
"success": false,
"errors": ["The package \"nicodevs/whether\" could not be found."],
"summary": {}
}
Агент отримує JSON з обох рівнів - і від інструмента, і від того, хто його запускає.
Тепер можна навчити агента ним користуватися: описати інструмент в AGENTS.md, у навичці (skill) або просто в промпті. З боку агента не треба встановлювати нічого, крім PHP і CPX, а перший запуск сам встановить пакет.
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.
Українською ця інструкція звучить так: коли користувач питає про погоду, виконай cpx nicodevs/weather з назвою міста, наприклад cpx nicodevs/weather "Tokyo"; команда виводить JSON із містом, температурою в градусах Цельсія та швидкістю вітру в км/год; якщо місто не знайдено, перевір назву й спробуй ще раз; про інші помилки повідом, а не міняй місто.
Ось і вся інтеграція. Жодного API-клієнта, жодного SDK, жодного сервера - одна команда, яку агент може запустити будь-де, де встановлено PHP.
Підсумок
Я обожнюю CPX! Він універсальний і дуже спрощує поширення пакетів. Інсталятори, CLI-інструменти, обгортки над API - можливості безмежні. Багато можливостей ми не встигли розглянути, тож неодмінно загляньте в документацію CPX.
Звісно, інструмент погоди, який ми сьогодні збудували, сам по собі не надто корисний: будь-який здібний AI-агент звернувся б до API напряму. Але створити ідеальний інструмент погоди ніколи й не було метою цієї статті. Справжня мета - показати структуру, яку можна повторно використати, щоб будувати інструменти для власних проєктів.
Питання в тому, що збудуєте ви?
До зустрічі!