# Custom CLI commands — the configbox custom namespace

> Add site-specific command-line commands to the ConfigBox CLI suite without touching core: one file in the upgrade-safe customization layer declares your comm…

Source: CBX documentation, version 3.x (released). Canonical page: https://docs.configbox.at/docs/customization/custom_cli_commands. Last updated 2026-08-18.

---
Add **site-specific command-line commands** to the ConfigBox CLI suite without touching core: one file
in the upgrade-safe customization layer declares your commands, and they appear under the reserved
`custom` namespace next to the stock commands. Typical uses: a nightly order export the host's cron
calls, a data-fix a support engineer runs once, an import from the shop's ERP — anything you would
otherwise script against the database directly, but with Kenedo booted and every ConfigBox helper at
hand.

All paths below are relative to the component root (on WordPress:
`docroot/wp-content/plugins/configbox/app/`). For the stock suite this extends, see
`technical/com_configbox_cli_commands.md`.

> **Platform status: all three hosts ship this.** Your `commands.php` is identical on every host —
> same fields, same callback signature, same return semantics. **Only two things differ, and both
> are the host's business, not yours:** where the file lives (§1) and how the command is spelled
> (§1.1), because each console has its own naming convention.

---

## 1. The contract

Create **one file** in the customization layer:

```
<customization dir>/cli/commands.php
```

The customization dir is wherever `KenedoPlatform::p()->getDirCustomization()` points on your host:

> **Per platform** (the overview's §1.1 table has the full story):
> - **WordPress:** `wp-content/plugins/configbox-customization/cli/commands.php` — the layer is its
>   own plugin-sibling directory, *not* inside the `configbox` plugin, so plugin updates never touch
>   it (no plugin registration needed; it is not a real WordPress plugin).
> - **Joomla / standalone:** `data/customization/cli/commands.php` inside the component.
> - **Magento 2:** `app/code/Rovexo/ConfigboxCustomizations/view/customizations/cli/commands.php`
>   (the `Rovexo_ConfigboxCustomizations` module's ConfigBox subtree). If that module does not exist
>   yet, create it — a `registration.php`, an `etc/module.xml` sequencing after `Rovexo_Configbox`,
>   and the `view/customizations/` tree is all it needs.

## 1.1 Two ways to run them, and what each host calls yours

Every command you define is reachable **two ways**, and you do nothing to get either.

**Declared** — each becomes a real command of the host's console: it shows up in the command list,
has its own `--help` built from your `shortdesc`/`synopsis`/`longdesc`, and tab-completes.

| Host | Your `order-count` becomes |
|---|---|
| Joomla | `php cli/joomla.php configbox:custom:order-count` |
| Magento 2 | `bin/magento configbox:custom:order-count` |
| WordPress | `wp configbox custom order-count` |

`custom` is a **namespace** on all three — a colon-separated one on the Symfony consoles, a
space-separated one on WP-CLI. **The name you key in `commands.php` is the same everywhere**; only
the separator differs.

**Dispatched** — the always-available fallback, where the name is an argument rather than part of
the command:

```bash
php cli/joomla.php configbox:custom                      # list what this site installs
php cli/joomla.php configbox:custom order-count 30
bin/magento configbox:custom order-count 30
wp configbox custom status                               # WordPress: the listing/diagnostic lives here
```

> **WordPress differs in one way, and it is forced.** Because `custom` is a WP-CLI namespace, it
> cannot also be a runnable command — WP-CLI refuses with *"'wp configbox custom' can't have
> subcommands"*. So there is no `wp configbox custom <name>` *dispatcher* form separate from the
> declared one (they are the same spelling), and the listing/diagnostic lives at
> **`wp configbox custom status`**. That makes **`status` a reserved command name on WordPress**:
> define one and it will not be registered, and `wp configbox custom status` will tell you so.
> `wp configbox custom` on its own gives WP-CLI's own namespace listing.

**Why both.** Declaring means the host reads your file while it is still building its console — on
every invocation. That is what buys the `list` entry and the help page. But if your file is broken,
declared commands can only *vanish* (there is nothing to report to yet — no output object exists,
and a warning at that point would corrupt machine-readable output such as `list --format=json`).
The dispatcher — `configbox:custom` on Joomla/Magento, `wp configbox custom status` on WordPress —
is where the failure is explained, in full, with the path and the parse error. So: **if your
command is missing from the list, run that and it will tell you why.**

It must **return an array** of command definitions, keyed by command name:

```php
<?php
defined('CB_VALID_ENTRY') or die();

return array(

	'order-count' => array(
		'shortdesc' => 'Count orders placed in the last N days.',      // one-liner for the listing
		'synopsis'  => '[<days>]',                                     // optional, shown in the listing
		'longdesc'  => "Counts order records created in the last N days.\n\n"  // optional, the --help body
			. "  <days>   how far back to look (default 30)",
		'callback'  => function (array $args, array $options) {

			$days = isset($args[0]) ? max(1, (int) $args[0]) : 30;

			$db = KenedoPlatform::getDb();
			$db->setQuery("SELECT COUNT(*) FROM `#__cbcheckout_order_records`
				WHERE `created_on` >= DATE_SUB(NOW(), INTERVAL " . (int) $days . " DAY)");

			echo $db->loadResult() . "\n";

			return 0;                                                  // exit code; null counts as 0
		},
	),

);
```

Run it — declared (its own command, with a help page) or dispatched (the fallback), see §1.1:

```bash
# Joomla                                    # Magento 2                                 # WordPress
php cli/joomla.php configbox:custom:order-count 7
bin/magento configbox:custom:order-count 7
wp configbox custom-order-count 7

php cli/joomla.php configbox:custom         # the dispatcher: list what this site installs
wp configbox custom order-count 7           # the dispatcher: run one
```

**Per definition:**

| Key | Required | Meaning |
|---|---|---|
| *(array key)* | yes | The command name — lowercase letters, digits and hyphens, starting with a letter or digit (`order-export`, not `Order_Export`). |
| `callback` | yes | Any PHP callable. Receives `(array $args, array $options)`, prints its own output (`echo`), returns an `int` exit code (`null`/no return counts as `0`). |
| `shortdesc` | no | One-line description, shown in the `custom` listing. |
| `synopsis` | no | A usage hint for your arguments (e.g. `<days> [--format=<fmt>]`), shown in the listing and at the top of the help page. Documentation only — nothing is validated against it. |
| `longdesc` | no | The **help body**, free-form text. Shown by `--help` (Joomla/Magento) and `wp help configbox custom <name>` (WordPress). This is where the real explanation goes: what the command does, what each option means, what it costs to run. Without it a declared command's help page has a one-line description and nothing else. |

**The callback's inputs:**

- `$args` — the positional arguments after the command name, in order, as strings
  (`wp configbox custom order-count 7` → `array('7')`).
- `$options` — the `--key=value` options as `key => value` strings; a bare `--flag` arrives as
  `key => true`.

**The callback's environment:** Kenedo is fully booted before any custom command runs — the database
(`KenedoPlatform::getDb()`), every ConfigBox model/helper, and the platform layer are available, and
outstanding migrations have been applied by the boot as usual. The command runs **as a guest**; if it
must act as a user, resolve and set one explicitly
(`ConfigboxCliHelper::resolveUserId()` + `ConfigboxUserHelper::setUserId()`), the same way the stock
`run-task` command does.

## 2. Failure isolation — why a broken file cannot break the stock suite

> **Declaring changed when your file is read, not whether it is contained.** Because declared
> commands must exist before the console runs, the host now reads `commands.php` on every
> `configbox`-related invocation, not only when a custom command is called. Every one of those
> reads is wrapped in `catch (Throwable)` — and a PHP **parse error is a catchable `ParseError`**,
> so even an unparsable file costs you your custom commands and nothing else. The guarantee below
> is unchanged; it simply now has two places to hold rather than one. Verified on all three hosts:
> with `commands.php` deliberately unparsable, the declared commands disappear from the list and
> `configbox:migrate --status` still runs.

The commands file is loaded **only when the `custom` dispatcher itself is invoked**, and always behind
a catch-everything guard. The stock commands (`cache-clear`, `migrate`, …) never load customization
code. Consequences you can rely on:

- A `commands.php` that **throws at load**, violates the contract, or even has a **PHP parse error**
  breaks only `wp configbox custom …` — with a message naming the file and the violation. Every stock
  command keeps working, so the tooling you deploy and recover with is never taken down by the code
  you are debugging.
- A single command's callback **throwing** is contained too: the dispatcher reports
  `Custom command "<name>" failed: <message>` and exits non-zero. Other custom commands are unaffected.
- Contract violations are rejected with precise messages (non-array return, bad command name,
  missing/uncallable `callback`) rather than half-registering.

There is deliberately **no** WP-CLI per-command registration of custom commands: registering them as
first-class subcommands would mean executing customization code on *every* `wp` invocation, which is
exactly the coupling this design avoids.

## 3. Conventions and gotchas

- **Exit codes are the API for scripts.** Return `0` for success and a non-zero `int` for failure so
  cron jobs and deploy scripts can gate on your command (`wp configbox custom nightly-export || alert`).
  The dispatcher passes your code through (`WP_CLI::halt()`).
- **Print raw, parseable output** for anything a script consumes (one value per line, or CSV/JSON) —
  the dispatcher does not wrap or decorate your stdout.
- **Avoid WP-CLI's global option names.** WP-CLI consumes `--user`, `--url`, `--path`, `--debug`,
  `--quiet` and the other [global parameters](https://make.wordpress.org/cli/handbook/references/config/)
  before your command sees them — name your options something else (the stock suite renamed its
  acting-user selector to `--cb-user` for the same reason).
- **Option values are strings; flags are `true`.** Cast numerics yourself; check flags with
  `!empty($options['flag'])`.
- **Follow the Kenedo idioms** for anything touching the database: manually built mysqli through
  `KenedoPlatform::getDb()`, `(int)` casts / `getEscaped()` for values — same escaping discipline as
  neighboring core code. No namespaces, no PSR-4.
- **Long-running work:** a CLI process has no web timeout, but migrations-style bookkeeping (locks,
  idempotency) is on you. For schema changes, don't do them in a command — use the customization
  migration track (`data/customization/updates/`, see `technical/com_configbox_migrations.md`).
- **Names collide only within your own file.** The `custom` namespace is reserved for the
  customization layer, so a ConfigBox update can never introduce a stock command that shadows yours.

## 4. How it resolves in code (for the curious)

| Piece | Where | Role |
|---|---|---|
| Declaration (per host) | Joomla: the `console` plugin's `registerCommands`. Magento: a `preference` on `CommandListInterface` — a *plugin* is never called, the CLI builds its command list before interception is active. WordPress: `WP_CLI::add_command()` at plugin load, from a raw read, because Kenedo is not booted that early. | Each host's own wiring; the contract they feed is the same. |
| Contract + loader + runner | `helpers/cli.php` — `ConfigboxCliHelper::getCustomCliCommandsPath()` / `loadCustomCliCommands()` / `executeCustomCliCommand()` | Platform-agnostic core: path (`getDirCustomization() . '/cli/commands.php'` — resolves per host, see §1), validation, exit-code normalization. Shared by all hosts. |
| WordPress dispatcher | `cli.php` (adapter, `cbx-wordpress` repo) — `ConfigboxCliCommand::custom()` | Boots Kenedo, lazily loads the file behind `catch (Throwable)`, lists or dispatches, maps exceptions/exit codes to WP-CLI. |
| Registration | `configbox.php` (adapter) | Registers only the dispatcher (`wp configbox custom`), like any stock subcommand. |

## See also

- `technical/com_configbox_cli_commands.md` — the stock CLI suite this extends, including the
  architecture split (shared core vs. thin host wrappers) your commands plug into.
- `com_configbox_customization_overview.md` — the extension-point map; this is one row of it.
- `technical/com_configbox_migrations.md` — the customization migration track, for delivering any
  schema your commands rely on.
- `com_configbox_events_and_observers.md` — prefer an observer when you want to *react* to something
  happening in the app; a CLI command is for work an *operator or cron* initiates.
