CBX CLI command suite
- Version
- 3.x
- Updated
Maintenance contract: this document is the reference for the CLI suite and must change in the same commit as the commands. If you add, remove or change a command, an option, an argument, a default, an exit code, or a
ConfigboxCliHelpermethod: update §1 (the command's section), §4 (the reference table), §2 (if the core/wrapper split changed), and the command's ownsetHelp()text. A repo hook reminds about this when files undersrc/Console/orhelpers/cli.phpare edited — do not dismiss it.
Scope: the
configbox:*console commands — every command in detail, the shared core, and the WordPress and Magento wrappers · Last reviewed: 2026-08-02
CBX ships a set of command-line commands, built the modern Joomla 4/5 way (the Symfony-Console
based joomla binary — not the deprecated Joomla 3 JApplicationCli scripts). They are exposed
through a small console-group plugin and run on the same joomla console every other Joomla 5 command
uses.
The bulk of each command's logic lives in a platform-agnostic core so the same behaviour can be
re-exposed on other hosts with only thin per-platform wrappers. WordPress (WP-CLI) is already
implemented (wp configbox …, in the cbx-wordpress repo), and so is Magento
(bin/magento configbox:…, in the rovexo/cbx-magento module repo) — see
§5 Reuse on WordPress & Magento.
This document describes the Joomla suite in detail (§1–§4). The commands, arguments and behaviour are identical on WordPress and Magento — the full suite, including
configbox:migrate --statusandconfigbox:migrate:unblock, exists on all three hosts; only the invocation binary and a couple of host quirks differ (§5).
1. The commands
All commands are run through the Joomla console binary from the site root:
php cli/joomla.php <command> [arguments] [options]
All commands live under the configbox namespace, grouped by area (cache:, config:, sysvar:):
| Command | What it does |
|---|---|
configbox:cache:clear | Clear all CBX caches. |
configbox:migrate | Apply outstanding CBX migration (update) scripts (--status to only report). |
configbox:migrate:unblock | Lift the freeze a failed migration puts on the update system — explained, confirmed, logged. |
configbox:charset | Convert CBX tables to the recommended character set / collation (utf8mb4_unicode_ci); --status to only report. |
configbox:run-task | Run a CBX controller task from the CLI, exactly as the /cb-api/ endpoint runs it over HTTP. |
configbox:mcp | Run an MCP (Model Context Protocol) server on stdio so an AI assistant can work with the catalog and the store through typed tools. --scope (<area>:<level>, e.g. catalog:write) and --preset narrow what it may do. The same tools are served over HTTP at POST /cb-api/v1/mcp, where a bearer token's scopes decide instead. Documented separately in com_configbox_mcp_server.md. |
configbox:config:get | Read a CBX setting value. |
configbox:config:set | Set a CBX setting, validated exactly like a UI save. |
configbox:config:list | List all settings with type, editability and current value. |
configbox:sysvar:get | Read a value from the low-level system-vars store. |
configbox:sysvar:set | Write a value to the system-vars store. |
configbox:sysvar:list | List all system-vars. |
configbox:generate-types | Regenerate the typed record classes and JSON schemas from the models' property definitions (--check to only verify). |
configbox:strings:purge | Remove translation rows whose record no longer exists (--status to only report). |
configbox:token:mint | Issue a bearer token for the REST API at /cb-api/v1/. Shown once. |
configbox:token:list | List the API tokens with scopes, expiry and last use. No secrets. |
configbox:token:revoke | Turn one API token off. |
configbox:token:scopes | Print the scope vocabulary — areas, the twelve scopes, presets, rules — from the code that enforces it. --json for scripts. |
configbox:api:export | Write the API description as OpenAPI, a Postman collection and an HTML reference. |
configbox:chatadvisor:analyze | Write lessons-learned reviews for idle, unanalyzed chat advisor conversations (cron-able). |
configbox:chatadvisor:relay | Send vendor-stakeholder recommendations to the vendor's collection endpoint (opt-in; --dry-run previews the envelope). |
configbox:payments:poll | Re-ask poll-capable payment providers about pending payments — the lost-return/webhook recovery (cron-able). |
Run php cli/joomla.php list to see them, or php cli/joomla.php help <command> for full built-in help.
Settings vs system-vars. The
config:*commands manage admin settings — the same values the CBX Settings screen edits (ConfigboxModelConfig), with the model's validation. Thesysvar:*commands manage the low-level internal bookkeeping store (#__configbox_system_vars: update version, the failed-update flag, last cleanup time). Useconfig:*for configuration; reach forsysvar:*only for support/recovery.
1.1 configbox:cache:clear
Clears every CBX cache (calculations, translations, answer/pricing/product data and the assignment maps). Caches rebuild lazily on next access, so there is nothing to warm afterwards.
php cli/joomla.php configbox:cache:clear
APCu note. A web server's APCu is a separate memory space a CLI process cannot reach directly.
purgeCache()handles this with a cache generation: a stamp on disk that is part of every APCu key prefix. Clearing bumps the stamp, which renames the key namespace for all processes — the web workers' entries become orphans (evicted by APCu later) and rebuild lazily. So a CLI clear is fully effective for web APCu, no PHP-FPM reload needed. The command warns only if the generation file (<cache-dir>/configbox_cache_generation) is not writable.
Under the hood: ConfigboxCacheHelper::purgeCache() (the single, platform-abstracted clear-all).
Host cache clears also clear CBX (each host's adapter wires its native cache-clear into
purgeCache()):
- Magento 2:
bin/magento cache:flush/cache:cleanand the admin's Cache Management buttons (the module observes Magento's cache-flush events).- Joomla: the admin's Clear Cache screen — Delete All, per-group delete and Purge Expired all dispatch
onAfterPurge, whichplg_system_configboxhandles. (Joomla core has no cache CLI command; on the CLI useconfigbox:cache:clear.)- WordPress:
wp cache flush(WP-CLI) — the plugin hooksafter_invoke:cache flushand reports "CBX caches cleared as well."
1.2 configbox:migrate
Applies every CBX update script that has not run yet — both the core track and the
customization track — and records the installed version in #__configbox_system_vars. Safe to run
repeatedly; already-applied scripts are skipped. The output names the scripts it applied (not a vague
"applied or up to date").
php cli/joomla.php configbox:migrate
| Option | Effect |
|---|---|
--status | Report installed version, newest shipped version and every pending core/customization script — applies nothing. Exits non-zero when work is due (or the install is frozen / fresh), so deploy and CI scripts can gate on it. |
--clear-failed-flag | Clear a stuck failed_update_detected flag from a prior failed run before applying updates. |
-v | On failure, additionally print the full stack trace (the failing script, exception chain and migration-log tail are printed even without it). |
Why --status exists: CBX tracks its schema version in #__configbox_system_vars, not in
the host's extension registry — so Joomla's extension version (and Magento's setup:db:status) report
"up to date" while CBX migrations are outstanding, and the only symptom is a 500 on a product page.
--status makes that state visible and scriptable:
php cli/joomla.php configbox:migrate # deploy step: apply
php cli/joomla.php configbox:migrate --status # deploy gate: fails the deploy if anything is still pending
On failure, the command prints the failing script's version, the exception chain (the innermost
exception usually carries the real DB error; the outer one is often a wrapper), and the tail of
logs/configbox/configbox_upgrade_errors.log inline — then points at configbox:migrate:unblock. The
failure is also persisted (failed_update_script, failed_update_error in the system-vars), so the
freeze can explain itself later, on --status, on any machine.
Exit codes: 0 = applied or nothing pending; non-zero = a script failed, updates are frozen, another
process holds the update lock, or (with --status) work is outstanding.
Under the hood: ConfigboxUpdateHelper::applyUpdates() (self-guarding: fresh-install detection, both
tracks, a MySQL GET_LOCK advisory lock, version bookkeeping) and
ConfigboxUpdateHelper::getPendingUpdates() for --status.
Note: CBX also applies outstanding updates automatically on every framework init (an
onConfigboxInitializedobserver). This command is the explicit, operator-driven way to run them — useful in a deploy step so the first web request doesn't pay the cost, and so failures surface in your deploy log rather than to a visitor. The migration commands themselves boot withCB_SUPPRESS_AUTO_UPDATESdefined so their own bootstrap does not apply migrations behind their back — without that,--statuswould apply the very migrations it was asked to only report on.
1.3 configbox:migrate:unblock
Lifts the freeze a failed migration puts on the update system. After an update script throws, CBX
sets failed_update_detected and refuses to run any further migration — deliberately, because the schema
is half-migrated and piling more scripts on top makes it worse. Nothing clears that automatically; this
command is the operator's explicit lift.
It explains before it changes anything: which script failed, with what error, whether another process
is migrating right now (the update lock), and the tail of the migration log — then asks for confirmation
(interactive runs; -n proceeds, since passing the flags non-interactively is already an explicit
decision).
php cli/joomla.php configbox:migrate:unblock --dry-run # explain the block, change nothing
php cli/joomla.php configbox:migrate:unblock # clear the freeze (confirms first)
php cli/joomla.php configbox:migrate # then resume — succeeded scripts stay skipped
| Option | Effect |
|---|---|
--dry-run | Report the block and what would be changed, then exit without changing anything. |
--skip-version=<v> | Also move the version pointer to <v>, so that script and everything below it counts as applied without running. Format-validated; confirmed. |
--track=<core|customization> | Which version pointer --skip-version moves (default core). |
--skip-version is the escape hatch, and a loaded gun. It exists for a script that cannot be made to
pass — its effect is already in the schema, or it does not apply to this install (the classic case: a
historical customization script referencing a table the core track already renamed). Every script at or
below the given version is marked applied and never runs; if the schema does not actually match, later
migrations fail in more confusing ways. Prefer fixing the script (idempotency guards) whenever possible.
Clearing the freeze also clears the recorded failure details and writes an audit line to the migration
log ("operator cleared the freeze"). Prefer this over sysvar:set failed_update_detected 0, which clears
the flag but leaves stale failure details behind and logs nothing.
Under the hood: ConfigboxUpdateHelper::unblockUpdates(); the lock probe is
ConfigboxUpdateHelper::isUpdateLockHeld() (IS_USED_LOCK — a held lock means a run is genuinely in
progress elsewhere and is not a stuck state; MySQL frees it if that process dies).
1.4 configbox:charset
Converts every CBX table that is not already on the recommended character set / collation —
utf8mb4 / utf8mb4_unicode_ci (the single global target ConfigboxUpdateHelper::TARGET_COLLATION) —
with ALTER TABLE … CONVERT TO CHARACTER SET. Safe to re-run; already-converted tables are skipped. It
is the CLI equivalent of the dashboard's Database Character Set fixer page, minus the per-request
batching a browser needs (a CLI run has no CDN timeout, so it converts everything in one pass with a
progress bar).
php cli/joomla.php configbox:charset # convert the non-conforming tables
php cli/joomla.php configbox:charset --status # list them, change nothing (non-zero exit if any)
Why it matters: utf8mb4 stores the full range of Unicode (the legacy utf8/utf8mb3 is 3-byte and
cannot hold emoji or some scripts), and a single collation across all CBX tables avoids
"illegal mix of collations" errors in queries that join them. Fresh installs are already on the target
(installFresh() normalizes after the DDL); this command brings existing installs — whose tables
accumulated mixed collations over years of migrations — up to the same state.
--status exits non-zero when any table is off-target, so a deploy step can gate on it. The chosen
collation is portable on purpose: utf8mb4_unicode_ci exists on MySQL 5.6/5.7/8.0+ and MariaDB, unlike
the MySQL-8-only utf8mb4_0900_ai_ci.
Under the hood: ConfigboxCliHelper::convertCharset() / getCharsetStatus(), over
ConfigboxUpdateHelper::getNonConformingCharsetTables() + convertTableToTargetCharset().
1.5 configbox:run-task
Runs a CBX controller task from the CLI, exactly as the /cb-api/ endpoint runs it over HTTP.
This is the CLI equivalent of requesting:
index.php?option=com_configbox&controller=<controller>&task=<task>&<params...>
The task's output is captured and printed to stdout verbatim (its "response body"). By default only the
component's own output is produced — output_mode defaults to view_only, so there is no page chrome
(you do not pass output_mode=view_only yourself; the CLI defaults to it).
php cli/joomla.php configbox:run-task <controller> [<task>] [<param>...] [options]
Positional arguments
| Argument | Meaning |
|---|---|
controller | The Kenedo controller name (required), e.g. cart, configuratorpage, testautomation. |
task | The task = a public method on the controller (default: display). |
param... | Any number of key=value request parameters — the GET/POST equivalents, space-separated. |
Options
| Option | Meaning |
|---|---|
-u, --user <selector> | Run as this user — CBX behaves as if the user is logged in (carts/orders/config resolve to them). Accepts an e-mail, a CBX user id, or platform:<host-user-id> (the Joomla/WP/Magento user id, mapped to its CBX user). Omit to run as a guest. |
--output-mode <mode> | view_only (default — component output only), in_html_doc, or in_platform_output. |
Examples
# Run the "display" task of the cart controller as a guest
php cli/joomla.php configbox:run-task cart
# Run a task with parameters (GET/POST equivalents), as a particular user by e-mail
php cli/joomla.php configbox:run-task configuratorpage makeselection \
questionId=112 selection=3 pageId=29 \
--user=customer@example.com
# Run as a CBX user id, or as a mapped host (Joomla) user id
php cli/joomla.php configbox:run-task cart display --user=42
php cli/joomla.php configbox:run-task cart display --user=platform:815
There is no separate GET-vs-POST flag: CBX reads request parameters through its KRequest
abstraction, which does not distinguish the two, so any key=value you pass is visible to the task.
Asset URLs under CLI. The Joomla console has no real request host, so absolute URLs in rendered output default to a placeholder (
https://joomla.invalid/set/by/console/application/...). If a task's output must contain correct URLs, pass the console's global--live-siteoption (or set a reallive_siteinconfiguration.php):php cli/joomla.php configbox:run-task <controller> <task> --live-site=https://www.example.com
Admin controllers work from the CLI — shell access is the credential. Decided 2026-07-31, with
the same reasoning the surfaces doc gives for MCP carrying no gate at all: whoever can run
cli/joomla.php can already edit the database directly, so the admin gate
(KenedoController::isAuthorized()) passes for CLI processes instead of asking the host for a
logged-in admin no CLI process can have. Before this, every admin* controller was refused from
run-task however --user was set — an accident of the host check, not a decision — while MCP
wrote freely on the same trust boundary. --user is unrelated: it names the acting customer
(whose cart, whose orders), never an admin identity.
Under the hood: ConfigboxCliHelper::runTask() — injects the request via KRequest::setVar(), sets the
acting user via ConfigboxUserHelper::setUserId(), dispatches the Kenedo controller task, and captures
the output buffer. It is the same dispatch the web entry file (components/com_configbox/configbox.php)
and the cb-api system plugin perform, so apart from the admin gate above, a task cannot tell it is
being run from the CLI.
Calls are isolated from each other. Both of those injections are one-way by nature — KRequest
has no unset, and the acting user lives in the session — which does not matter for a web request (one
request, one process) but does when several tasks run in one process, as the MCP server does. So
runTask() snapshots the request state and the acting user on the way in and restores them in a
finally on the way out, exceptions included (KRequest::getSnapshot()/restoreSnapshot(),
ConfigboxUserHelper::getUserIdSnapshot()/restoreUserIdSnapshot()). A task therefore sees only the
parameters it was given and only the user it was given; before this, "omit --user to run as a guest"
quietly meant "keep whichever user the previous call impersonated". The snapshot helpers are inert
unless something calls them, so nothing outside this path changes.
1.6 configbox:config:* — admin settings
These read and write the CBX settings — the same values the admin Settings screen edits
(backed by ConfigboxModelConfig, a single row id=1). Setting a value runs the same validation
the UI runs (the model's validateData()); if validation fails, nothing is written and the errors are
printed.
# List settings (name, type, editable-or-why-not, current value)
php cli/joomla.php configbox:config:list
php cli/joomla.php configbox:config:list --editable-only --language=de-DE
# Read one setting
php cli/joomla.php configbox:config:get weightunits
php cli/joomla.php configbox:config:get blocktitle_cart --language=de-DE
# Set one setting (validated like a UI save)
php cli/joomla.php configbox:config:set weightunits kg
php cli/joomla.php configbox:config:set blocktitle_cart "Warenkorb" --language=de-DE
Multi-language (translatables). Some settings (e.g. the blocktitle_* block, url_segment_*) are
translatable — stored per language in #__configbox_strings. Use -l, --language <tag> (e.g. en-GB,
de-DE) to read or write a specific language. set loads the whole record first, so writing one
language preserves the others (it will not blank out the languages you did not pass). Without
--language, the current language is used.
Editable vs not. config:list marks each setting editable or not:
| Editable | Setting types |
|---|---|
| yes | string, boolean, dropdown, join (scalar settings), and translatable (per language). |
| no | groupstart/groupend (layout), note (display), childentries (sub-listings), multiselect (xref-backed, e.g. active_languages), image (file upload), id (the record key), and any invisible setting. |
config:set refuses non-editable settings with a reason. Manage those in the admin UI (an image upload,
a multi-select, a sub-listing like currencies/tax classes).
Validation is the model's, warts and all. The command runs exactly the validation a UI save runs — no more, no less. Notably the
joinsettings (e.g.language_tag,default_country_id) validate the shape but not that the id/tag actually exists, mirroring the admin form. Pass real values.
Under the hood: ConfigboxCliHelper::getConfig() / setConfig() / listConfig(). setConfig() loads the
full record via getRecord(1, $lang), overlays the one field, runs prepareForStorage() +
validateData() + store() (the property system persists translatables to #__configbox_strings
itself), then clears the cache so reads see the change.
1.7 configbox:sysvar:* — internal bookkeeping store
Low-level access to #__configbox_system_vars, a plain key/value string store CBX uses for
internal state (latest_update_version, latest_customization_update_version,
failed_update_detected, failed_update_script, failed_update_error, last_cleanup, license-check
results, ...). This is not for configuration — for that use config:*.
php cli/joomla.php configbox:sysvar:list
php cli/joomla.php configbox:sysvar:get latest_update_version
php cli/joomla.php configbox:sysvar:set failed_update_detected 0
sysvar:get prints the raw value on stdout (nothing + exit 1 if the key is missing), so it is
scriptable. Values are always strings.
Caution. These are internal bookkeeping vars. A wrong value (a bogus
latest_update_version, or flippingfailed_update_detected) changes how CBX behaves on the next init.sysvar:setis for support and recovery — not routine use. For the migration vars specifically, prefer the purpose-built commands:configbox:migrate:unblockclearsfailed_update_detected(plus the recorded failure details, with an explanation and an audit log line), and its--skip-versionmoveslatest_update_versionsafely (format-checked, confirmed). A rawsysvar:setdoes none of that.
Under the hood: ConfigboxCliHelper::getSystemVar() / setSystemVar() / listSystemVars() →
ConfigboxSystemVars::getVar() / setVar().
1.8 configbox:generate-types
Regenerates one typed record class and one JSON schema per entity, from the models' property definitions. Both artifacts come out of a single pass, so they cannot describe different shapes.
# Regenerate the committed base artifacts
php cli/joomla.php configbox:generate-types
# Verify they still match the definitions; writes nothing, non-zero on drift (for CI)
php cli/joomla.php configbox:generate-types --check
# Diagnostic: which properties' declared dataType/nullable disagree with the live table
php cli/joomla.php configbox:generate-types --schema-drift
--checkis language-sensitive. Translatable fields generate one<name>_<languageTag>key per active language of the generating site, so the committed artifacts encode the language set of the site they were generated on. A site with a different set (e.g. a WordPress dev site running onlyen-USagainst artifacts generated withen-GB+de-DE) fails--checkby design — do not "fix" that by committing a regeneration from such a site.
Which models take part is decided by KenedoModel::getEntityName() — a model returning a name is
generated under it, one returning '' is skipped. Output is deterministic (fields alphabetical, no
timestamps), so re-running without a definition change produces no diff.
Writable directories. The command needs write access to components/com_configbox/generated/
(records/ and schemas/). It creates them if missing and fails with a clear message if it cannot.
It never writes anywhere else. (The former --overlay mode is gone — the generator has one mode,
base, and the customization definitions are baked into the committed set.)
Full detail, including the artifact-set split and the property vocabulary, is in
com_configbox_type_generation.md.
1.9 configbox:strings:purge — orphaned translation rows
Removes #__configbox_strings rows whose owning record no longer exists.
# Report per string type; writes nothing
php cli/joomla.php configbox:strings:purge --status
# Remove them, and purge the caches after
php cli/joomla.php configbox:strings:purge
Why they happen. A translatable field's text lives in the strings table, keyed by langType +
the record id — not on the record. Deleting a record through its model runs
KenedoPropertyTranslatable::delete() and takes the strings with it. Anything that deletes the row
directly with SQL does not, and the strings stay behind with nothing pointing at them. E2E
teardown used to be the main producer (it deletes structures bottom-up, which the models refuse);
that path derives its string types from the models now, but historical rows remain.
Why it is not merely untidy. The translation cache loads the entire strings table for a language, so every orphan is re-read into memory on every request that renders anything. The ConfigBox dev site had reached 13,522 orphans against ~600 live rows.
Which types are checked is derived, not listed. Every translatable property's langType, paired
with its own model's table and key (ConfigboxCliHelper::getStringTypeOwners()). A hand-kept list is
the kind that silently stops covering a field somebody adds later. A type no model claims is
reported and left alone — without a known owner, a row cannot be shown to be an orphan.
The delete uses the same LEFT JOIN that identified the rows, so it can only remove what --status
would have listed.
1.10 configbox:token:* — API bearer tokens
Issues, lists and revokes the bearer tokens for the REST API at /cb-api/v1/ (see
com_configbox_entity_api.md) and for MCP over HTTP at /cb-api/v1/mcp (see
com_configbox_mcp_server.md) — one credential, one scope vocabulary, both surfaces. The API Tokens admin screen (since 2026-08) manages the same
rows through the same helper; these commands remain the login-free route for scripts and
provisioning. Four commands — and the vocabulary they share is documented once, in
com_configbox_api_tokens_and_scopes.md:
php cli/joomla.php configbox:token:scopes # the areas, the 12 scopes, the presets, the rules
php cli/joomla.php configbox:token:mint "catalog assistant" --preset=author
php cli/joomla.php configbox:token:mint "fulfilment bot" --scope=orders:write --scope=customers:read
php cli/joomla.php configbox:token:mint "reporting" -s catalog:read --expires="+90 days"
php cli/joomla.php configbox:token:mint "pricing dev" --preset=author --scope=code:write
php cli/joomla.php configbox:token:list
php cli/joomla.php configbox:token:revoke 7
Minting is a CLI command and not an admin screen, deliberately. Running it already requires shell access to the site, which is a strictly higher bar than a backend login, and it keeps the secret out of a browser's history and out of any proxy or access log that records URLs.
The token is printed once and cannot be retrieved afterwards. Only its SHA-256 is stored, so there is no command, screen or query that can show it again — which is exactly the property that makes a leaked database useless. Lost one? Revoke it and mint another; it costs one command.
mint writes the token to stdout on its own final line, unstyled, so … | tail -1 captures exactly
the token and nothing else.
A scope is <area>:<level>. Seven areas — catalog, store, customers, orders,
conversations, code, system — each read or write. Within an area write implies read;
across areas nothing implies anything; every token reads store (minus its secret fields) whether or
not it is granted, because that is the vocabulary every other area is expressed in. code:write is
permission to run PHP on the server and is never bundled by a preset — spell it out explicitly, on top
of catalog:write, when a token genuinely needs to author code calculations. An unknown scope is
refused rather than dropped — silently dropping it would mint a token that looks like it grants
something and does not, and the mistake would surface later as a confusing 403 far from the typo
that caused it.
--preset expands a named bundle at mint time and is then forgotten — the token row stores only
the resulting scopes, so renaming or retiring a preset later never touches an already-minted token.
Five exist: author (catalog:write), fulfilment (orders:write + customers:read), insights
(catalog:read + conversations:read), operator (store:write + system:write), everything
(every area but code, at write). --preset combines with --scope — the last example above adds
code:write on top of author. No preset includes code:write.
The old read / write / admin names still work, for one release. mint expands them and
prints a deprecation notice naming what each became: read → every read scope, write →
catalog:write and code:write (the old write could already author code calculations, so
narrowing it is left to the admin, not folded silently into the migration), admin → the four
store-running writes (store, customers, orders, system, all :write). Migration 3.8.17
rewrote every already-stored token's scopes through the same expansion (and widened the scopes
column to VARCHAR(512) — a token holding all twelve scopes is longer than the old vocabulary ever
produced) — nothing narrowed automatically, so an existing token kept everything it had. Use the new
names directly; the old ones stop working next release.
--expires accepts anything strtotime() reads, including relative forms ("+90 days"). Stored and
compared in UTC. Prefer setting one: a token with no expiry is one still being trusted in three
years, long after everyone has forgotten which machine holds it.
list shows no secret and no hash, so its output is safe to paste into a ticket. Its State column
is the effective verdict rather than the stored flag — a token past its expiry reads expired
even though nothing in the database changed, because that is what the API will do with it.
revoke flags the row rather than deleting it, so "which token was that, and when did we turn it
off" survives — the question asked after an incident. There is no un-revoke.
Exit codes: 0 on success; non-zero for an unknown scope, an unknown --preset, an unreadable
--expires, or a revoke of an id that does not exist.
1.11 configbox:api:export — the API description, as files
Writes this store's API description in the three forms people consume, all from one source (the OpenAPI document) so they cannot describe different APIs.
php cli/joomla.php configbox:api:export --dir=./api-docs
php cli/joomla.php configbox:api:export -f postman -d /tmp
| File | What it is |
|---|---|
openapi.json | OpenAPI 3.1, every schema inlined. Import into Postman, Insomnia, Bruno, Swagger UI, Redoc, or a client generator. |
configbox.postman_collection.json | Postman Collection v2.1, ready to run: bearer auth on a cbxToken variable, {{baseUrl}} already pointing here, :id as real path variables, folders carrying each group's description. |
api-reference.html | One self-contained page — every endpoint, every request and response field, with descriptions. No CDN, no scripts; it renders from a memory stick. |
--format (-f) is repeatable and takes openapi, postman or html; omit it for all three.
--fragment writes the HTML reference without its document wrapper — no <html>/<head> — for
embedding in a page that brings its own.
--dir (-d) defaults to the current directory and is created if missing. It is normalised before
any path is built from it, so the shapes tab completion produces — --dir tmp/, --dir ./out/./,
--dir a//b — report back as tmp/openapi.json rather than tmp//openapi.json.
The schemas are inlined rather than $ref-ed to the served URLs, and that is the whole point.
Those URLs need a bearer token, and no importer sends one while dereferencing — an earlier version
$ref-ed them and Postman silently produced a collection with empty request bodies.
It describes THIS store, from the live install: its entity list (including any a customization
adds), its active languages, its base URL. It carries no data, only shapes. Mint a token for whoever
receives it with configbox:token:mint, and give them the narrowest scope that works.
A scope is <area>:<level>, and areas are independent — catalog:write neither implies nor is
implied by store:write, orders:write or any other area (write does imply read within an area).
catalog is authoring the catalog; store, customers, orders and system are running the store
(shipping and tax, customer and order records, migrations). A catalog importer wants catalog:write
and must not be given store:write; an operations assistant wants store:write + system:write and
must not be given catalog:write. §1.10 above has the full vocabulary, and --preset (e.g.
--preset=author, --preset=operator) covers both of these directly.
The base URL comes from --live-site (or a real live_site in configuration.php) — under CLI
there is no request to read it from. Without either, every URL in the export carries Joomla's
joomla.invalid placeholder host; the command warns and still writes the files, since the fix is a
re-run away and the rest of the export is unaffected.
Exit codes: 0 on success; non-zero for an unknown --format, an unwritable directory, or a failure
building the document. The joomla.invalid warning does not change the exit code.
1.12 configbox:chatadvisor:analyze — lessons-learned reviews
Reviews finished chat advisor conversations: one Claude call per idle, unanalyzed conversation over
the full transcript and tool trace, producing the compact lessons-learned text the analysis agent
later reads in bulk. The review lands on the conversation row and in the cumulative
custom_chatadvisor_lessons.log stream. Meant for cron; a conversation is analyzed once.
php cli/joomla.php configbox:chatadvisor:analyze [--idle-minutes=30] [--max=20]
| Option | Effect |
|---|---|
--idle-minutes=<n> | Only conversations without a turn for this long are analyzed (default 30) — an active one would get a premature review. |
--max=<n> | Conversations per run (default 20) — the lid on API spend. |
If the chat advisor is not configured on the site
(data/store/private/settings/chatadvisor.json), the command says so and exits 0 — nothing to do
is not a failure. Exit codes: 0 = ran (even with nothing due); 1 = at least one analysis failed.
Under the hood: ConfigboxChatAdvisorHelper::analyzeIdleConversations() (shared core; the wrapper
only parses options and prints the analyzed/failed lists).
1.12b configbox:chatadvisor:relay — vendor recommendations home
Sends every vendor-stakeholder recommendation that is new or changed since its last transmission
to the vendor's collection endpoint (the CBX Insights Hub — AI settings ai_vendor_relay_url).
The scheduled analysis sweep relays automatically when ai_vendor_relay is on (strictly opt-in,
off by default — the payload quotes visitor conversations); this command runs it by hand.
php cli/joomla.php configbox:chatadvisor:relay [--dry-run]
| Option | Effect |
|---|---|
--dry-run | Print the exact versioned envelope as JSON, send nothing, stamp nothing. Works with the relay setting OFF — the way an operator inspects what would leave the site before opting in. |
The envelope carries schemaVersion, the install identity (a hash of the licence key, never
the key), and the recommendations with their evidence quotes. relayed_at on the recommendation
is the transmission watermark: sent once, re-sent only when the row changed.
Exit codes: 0 = sent, nothing due, dry-run, or relay off; 1 = the endpoint refused or was
unreachable.
Under the hood: ConfigboxChatVendorRelayHelper::relayPending() (shared core; the sweep calls
the same method after reconciliation).
1.13 configbox:payments:poll — the payments settlement backstop
# From cron: settle/fail pending payments whose return got lost or whose webhook never arrived.
php cli/joomla.php configbox:payments:poll --minutes=30 --limit=50
Re-asks every poll-capable provider (Stripe, Mollie, PayPal) about its PENDING payments last updated
at least --minutes ago. Outcomes run through ConfigboxPaymentService::applyOutcome() like every
other settlement channel — amount-verified, timeline-recorded, order side-effects applied. Providers
without polling support are skipped; their webhook or the admin ledger remains the path. See
docs/technical/com_configbox_payments.md §6 (channels) and invariant 9.
1.14 configbox:product:* — moving products between installs
configbox:product:export writes a product and everything belonging to it into a zip;
configbox:product:import puts one back. The Product Transfer admin screen does the same job with the
findings on screen; these are for the times a browser is the wrong tool — a staging→live deploy step, or
restoring a product a test removed.
php cli/joomla.php configbox:product:export --list # what is there
php cli/joomla.php configbox:product:export --sku=LAB-CARPORT -o /tmp/x.zip
php cli/joomla.php configbox:product:import /tmp/x.zip --dry-run # read the check
php cli/joomla.php configbox:product:import /tmp/x.zip # add as new products
php cli/joomla.php configbox:product:import /tmp/x.zip --mode=exact --force # replace the matching ones
An ambiguous --sku is refused, not resolved. SKUs are not unique in this schema, so picking the
first match would write a package that is right today and wrong tomorrow. The command lists the
candidates and asks for --id.
How warnings are handled — the part that differs from the screen. The screen collects a tick per kind of warning before it will import. There is nobody to tick a box on a CLI, so:
| Level | On the CLI |
|---|---|
| blocker | Always refuses. No flag overrides it, --force included. |
| warning | Refuses unless accepted deliberately: interactively by answering the prompt, non-interactively with --force. |
| notice | Printed, and proceeded past. |
So a deploy step that has not said --force gets a refusal and exit 1 rather than a silent overwrite —
"no answer" means no, because an exact-mode import deletes records and can take live cart rows with them.
--new-ids is the CLI form of the screen's "Give those items new IDs". In --mode=exact the package
keeps its record IDs, and an ID it wants can already belong to an unrelated product on the target;
overwriting it would silently change that product, so it is a blocker. --new-ids imports just the
clashing items under fresh IDs and leaves the other products alone, re-aiming everything inside the
imported product that referred to them. It is ignored (with a warning) in --mode=new, where every
record gets a new ID anyway.
References out of the package — tax classes, customer groups, product lists — are matched by name against the target's own records, never invented. One that cannot be found is reported and the field is left empty.
When it fails, four things are reported, because a CLI has nobody to ask a follow-up. What went wrong, where in the package, what state the install is in now, and where to look:
[ERROR] The import failed: import of product 20931 in exact mode -> import of Products ID 20931
-> import of Pages ID 71 -> import of Questions ID 402 -> import of Answers ID 118
-> Storing a record of model "ConfigboxModelAnswers": A system error occurred.
See identifier "yxlv8pi1tj" in the ConfigBox error log.
[WARNING] 1 of 2 product(s) were imported before the failure and are STILL HERE — each product is
committed on its own. This install now holds part of the package.
[NOTE] Logs are in <log dir>: custom_product_transfer.log (what this feature adjusted or refused),
configbox_errors.log (system failures, found by the identifier above), and — with ConfigBox
debug mode on — configbox_debug.log for the step-by-step trace.
The trail comes from KenedoException::addContext(), which every level of the import adds itself to; the
CLI prints getContextualMessage() rather than getMessage(), which is the difference between the above
and a bare "A system error occurred". The partial-landing warning matters because products import one
transaction each: a package that fails on its third product has left two behind.
With ConfigBox debug mode on the whole sequence is in configbox_debug.log — one line per record
(Product import: Answers 118 -> 4711), the pass boundaries, the prune counts and the commit. Grep for
Product import: / Product export:.
tools/check-transfer-failure-messages.sh in the cbx-joomla project forces every failure this feature
has (wrong file, truncated copy, unwritable target, a record the database refuses, a multi-product
package that fails half way) and prints what both surfaces say about each.
The same job over MCP is cbx_export_product / cbx_check_product_package /
cbx_import_product_package, where the equivalent of --force is acknowledge_warnings: true —
MCP server §3d.
Full detail of the package format, both modes and what is validated: Product transfer — §7b compares all three surfaces side by side.
1.15 Recipes — the commands composed
Deploy step (fails the deploy loudly instead of 500-ing the first visitor):
php cli/joomla.php configbox:migrate # apply; names what it ran
php cli/joomla.php configbox:migrate --status # gate: non-zero if anything is still pending/frozen
php cli/joomla.php configbox:cache:clear # caches rebuild lazily
"Is this install actually migrated?" (e.g. mid-CB4-upgrade, or a site that 500s on product pages):
php cli/joomla.php configbox:migrate --status
Recovering from a failed migration:
php cli/joomla.php configbox:migrate --status # which script failed, with what error
# fix the script (usually: add the idempotency guard — see com_configbox_migrations.md)
php cli/joomla.php configbox:migrate:unblock # lift the freeze (explains first, confirms)
php cli/joomla.php configbox:migrate # resume; succeeded scripts stay skipped
Skipping a script that cannot pass (its effect is already in the schema / not applicable here):
php cli/joomla.php configbox:migrate:unblock --skip-version=3.6.2 # loaded gun — read §1.3 first
Smoke-testing after a migration without a browser (renders exactly what the web endpoint would):
php cli/joomla.php configbox:run-task configuratorpage display page_id=29 --user=customer@example.com
2. Architecture — where the code lives
The suite is deliberately split into a reusable core and thin per-platform wrappers:
docroot/components/com_configbox/helpers/cli.php (shared submodule → same file on every platform)
ConfigboxCliHelper ← REUSABLE CORE (pure Kenedo, no Joomla/WP/Magento)
::clearCache()
::applyMigrations($clearFailedFlag) ← reports WHICH scripts it applied (before/after diff)
::getMigrationStatus() ← read-only pending/frozen/lock report (--status)
::unblockMigrations($clear, $setVersion, $track)
::getMigrationFailureDiagnostics($e) ← exception chain + failed script + log tail
::getUpgradeLogTail($maxLines) / getUpgradeLogPath()
::runTask($controller, $task, $params, $cbUserId, $outputMode)
::resolveUserId($selector)
::getSystemVar() / setSystemVar() / listSystemVars()
::generateTypes($mode) / checkTypes($mode) ← delegate to ConfigboxTypeGenerator
::reportTypeSchemaDrift() ← definitions vs. live columns (diagnostic)
::getConfig() / setConfig() / listConfig() (validation via ConfigboxModelConfig)
::parseKeyValueParams($pairs) ← console-agnostic input glue
::describeCacheResult($r) / describeMigrationResult($r) ← console-agnostic message glue
::describeMigrationStatus($s) ← ditto, for --status output
::getCustomCliCommandsPath() ← custom commands (§6): the one contract file
::loadCustomCliCommands() ← load + validate the definitions (lazy!)
::executeCustomCliCommand($def, $args, $options) ← run one, normalize the exit code
docroot/components/com_configbox/helpers/update.php (shared submodule)
ConfigboxUpdateHelper ← the migration engine the migrate commands drive
::getPendingUpdates() ← read-only; also reports failed_update_script/_error + lock
::unblockUpdates() ← clear freeze / move version pointer (audited to the log)
::isUpdateLockHeld() ← IS_USED_LOCK probe
::getScriptInProgress() ← version of the script in flight (set before each run)
docroot/administrator/components/com_configbox/src/Console/ ← Joomla wrappers (thin)
AbstractConfigboxCommand.php ← bootKenedo($applyUpdates = true) shared bootstrap + renderLines()
ClearCacheCommand.php → configbox:cache:clear
MigrateCommand.php → configbox:migrate
MigrateUnblockCommand.php → configbox:migrate:unblock
RunTaskCommand.php → configbox:run-task
ConfigGetCommand / ConfigSetCommand / ConfigListCommand → configbox:config:*
SysvarGetCommand / SysvarSetCommand / SysvarListCommand → configbox:sysvar:*
docroot/plugins/console/configbox/ ← the registration mechanism (a console-group plugin)
configbox.xml
services/provider.php
src/Extension/Configbox.php ← subscribes to ApplicationEvents::BEFORE_EXECUTE, addCommand()
The
parseKeyValueParams/describe*Resulthelpers are console-framework-agnostic glue (pure PHP, no Symfony/WP-CLI references) added so the WordPress and Magento wrappers don't re-implement the run-task param parsing and the cache/migrate result formatting the Joomla wrapper open-codes. They live in the shared core alongside the real work; the Joomla commands still open-code their equivalents but can adopt these too.
ConfigboxCliHelper contains only Kenedo/CBX calls that behave identically on every platform
CBX targets. Each host platform ships a thin CLI wrapper that (1) bootstraps its host far enough for
Kenedo to initialise, then (2) calls one ConfigboxCliHelper method. All the real work lives in the core.
The Joomla command classes are thin: they parse Symfony console input, call bootKenedo() (which sets a
SiteApplication as the current app and runs initKenedo('com_configbox')), call one core method, and
print the result with SymfonyStyle.
bootKenedo(false)— the migration commands' special boot. CBX normally applies outstanding migrations as a side effect of booting (observers/System.php). The twomigrate*commands passfalse, which definesCB_SUPPRESS_AUTO_UPDATESbefore boot so the observer skips that — they driveapplyUpdates()explicitly instead. Without this,--statuswould apply the migrations it was asked to report on, and a failing script would surface as a raw bootstrap stack trace instead of the command's diagnostics. It is a constant (not a helper flag) becauseConfigboxUpdateHelperdoes not exist until the boot registers its autoload entry. Every other command keeps the default (true).
3. How the commands are registered (Joomla 5)
Joomla 5 has no container tag or MVCFactory auto-discovery for component console commands. The
supported way for an extension to add commands is a plugin in the console group that subscribes to
the console application's ApplicationEvents::BEFORE_EXECUTE event and calls $application->addCommand().
That is exactly what plugins/console/configbox does. The command classes themselves live with the
component (namespace Joomla\Component\Configbox\Administrator\Console\*, autoloaded via Joomla's generated
administrator/cache/autoload_psr4.php); the plugin just wires them into the console app at run time.
The plugin must be installed and enabled for the commands to appear:
# Discover + install the plugin (it ships inside the CBX package)
php cli/joomla.php extension:discover
php cli/joomla.php extension:discover:install --eid=<discovered id>
# then enable "CBX - Console commands" (group: console) in Extensions → Plugins
Once enabled, php cli/joomla.php list shows the configbox command namespace.
4. Command reference summary
| Command | Arguments | Options | Core method |
|---|---|---|---|
configbox:cache:clear | — | — | ConfigboxCliHelper::clearCache() |
configbox:migrate | — | --status, --clear-failed-flag | ConfigboxCliHelper::applyMigrations() / getMigrationStatus() |
configbox:migrate:unblock | — | --dry-run, --skip-version=<v>, --track=<core|customization> | ConfigboxCliHelper::unblockMigrations() |
configbox:charset | — | --status | ConfigboxCliHelper::convertCharset() / getCharsetStatus() |
configbox:run-task | controller, [task=display], [param...] | -u|--user, --output-mode | ConfigboxCliHelper::runTask() |
configbox:mcp | — | -s|--scope (repeatable, <area>:<level>), -p|--preset, --read-only (= every read scope), --in-process, --call (internal) | ConfigboxMcpHelper::runStdioLoop() / executeTool() / setGrantedScopes() |
configbox:config:get | key | -l|--language | ConfigboxCliHelper::getConfig() |
configbox:config:set | key, value | -l|--language | ConfigboxCliHelper::setConfig() |
configbox:config:list | — | -l|--language, --editable-only | ConfigboxCliHelper::listConfig() |
configbox:sysvar:get | key | — | ConfigboxCliHelper::getSystemVar() |
configbox:sysvar:set | key, value | — | ConfigboxCliHelper::setSystemVar() |
configbox:sysvar:list | — | — | ConfigboxCliHelper::listSystemVars() |
configbox:generate-types | — | --check, --schema-drift | ConfigboxCliHelper::generateTypes() / checkTypes() / reportTypeSchemaDrift() |
configbox:strings:purge | — | --status | ConfigboxCliHelper::getOrphanedStringStatus() / purgeOrphanedStrings() |
configbox:token:mint | name | -s|--scope (repeatable, <area>:<level>), -p|--preset, --expires | ConfigboxApiTokenHelper::mint() |
configbox:token:list | — | — | ConfigboxApiTokenHelper::getTokens() |
configbox:token:revoke | id | — | ConfigboxApiTokenHelper::revoke() |
configbox:token:scopes | — | --json | ConfigboxApiTokenHelper::getAreas() / getKnownScopes() / getPresets() |
configbox:api:export | — | -d|--dir, -f|--format (repeatable), --fragment | ConfigboxOpenApiHelper::buildDocument() / ConfigboxPostmanHelper::buildCollection() / ConfigboxApiHtmlHelper::render() |
configbox:chatadvisor:analyze | — | --idle-minutes=<n>, --max=<n> | ConfigboxChatAdvisorHelper::analyzeIdleConversations() |
configbox:chatadvisor:relay | — | --dry-run | ConfigboxChatVendorRelayHelper::relayPending() |
configbox:payments:poll | — | --minutes=<n>, --limit=<n> | ConfigboxPaymentService::pollPending() |
configbox:product:export | — | --sku (repeatable), --id (repeatable), -o|--out, --list | ConfigboxProductTransferHelper::exportProducts() |
configbox:product:import | package | --mode=<new|exact>, --new-ids, -f|--force, --dry-run | ConfigboxProductTransferValidationHelper::validatePackage() / ConfigboxProductTransferImportHelper::importProduct() |
configbox custom (WordPress-only dispatcher so far — §6) | [command], [arg...] | any (passed through) | ConfigboxCliHelper::loadCustomCliCommands() / executeCustomCliCommand() |
Exit codes follow the Symfony convention: 0 success, non-zero failure (e.g. an unresolved --user, a
missing controller, a task exception, or a migration script error — each printed with a clear message).
5. Reuse on WordPress & Magento
The point of the split is that porting the suite to another platform means writing only the thin wrapper,
not the logic. Because ConfigboxCliHelper uses only KenedoPlatform::p() / Kenedo abstractions (cache
dir, DB, session, user helper — all platform-abstracted), no logic changes are needed; each platform only
supplies its own console plumbing and its own "bootstrap far enough that Kenedo can init" step.
5.1 WordPress (WP-CLI) — implemented
The WordPress host ships a working WP-CLI wrapper. These commands live in the WP host repo
(cbx-wordpress), not in this Joomla repo — the shared submodule only carries the core
(ConfigboxCliHelper).
docroot/wp-content/plugins/configbox/cli.php ← WordPress wrapper (thin; in the adapter, NOT the submodule)
ConfigboxCliCommand
bootKenedo() → require init.php + initKenedo('com_configbox')
cache_clear/migrate/run_task/config_*/sysvar_* → call the matching ConfigboxCliHelper method
docroot/wp-content/plugins/configbox/configbox.php ← registration
if (defined('WP_CLI') && WP_CLI) { require cli.php; WP_CLI::add_command('configbox <sub>', [class,method]); }
Usage mirrors the Joomla suite one-for-one, under the wp configbox namespace with hyphenated
subcommands:
wp configbox cache-clear
wp configbox charset [--status]
wp configbox migrate [--status] [--clear-failed-flag]
wp configbox migrate-unblock [--dry-run] [--skip-version=<v>] [--track=<core|customization>] [--yes]
wp configbox run-task <controller> [<task>] [<param>...] [--cb-user=<selector>] [--output-mode=<mode>]
wp configbox mcp [--in-process] [--read-only] # (+ the internal --call worker mode)
wp configbox config-get <key> [--language=<tag>]
wp configbox config-set <key> <value> [--language=<tag>]
wp configbox config-list [--language=<tag>] [--editable-only] [--format=<fmt>]
wp configbox sysvar-get <key>
wp configbox sysvar-set <key> <value>
wp configbox sysvar-list [--format=<fmt>]
wp configbox strings-purge [--status] [--format=<fmt>]
wp configbox generate-types [--check] [--schema-drift]
wp configbox token-mint <name> [--scope=<catalog:write,orders:read>] [--preset=<name>] [--expires=<when>]
wp configbox token-list [--format=<fmt>]
wp configbox token-revoke <id>
wp configbox api-export [--dir=<dir>] [--format=<openapi,postman,html>] [--fragment]
wp configbox chatadvisor-analyze [--idle-minutes=<n>] [--max=<n>]
wp configbox product-export [--list] [--sku=<a,b>] [--id=<1,2>] [--out=<path>]
wp configbox product-import <package> [--mode=<new|exact>] [--new-ids] [--force] [--dry-run]
wp configbox custom [<command>] [<arg>...] [--<option>...] # site-specific commands — §6
The product transfer's decisions live in the core, not in the wrappers.
listExportableProducts(),resolveExportTargets(),exportProductPackage(),checkProductPackage(),describePackageCheck(),mayImportPackage(),importProductPackage()anddescribeTransferFailure()are allConfigboxCliHelpermethods, so the rule about when an import is allowed - blockers always refuse, warnings refuse unless accepted deliberately, notices are printed - is decided and WORDED in one place. Three hosts and an MCP surface enforce it, and three of them disagreeing would be worse than any one of them being wrong. A wrapper owns only its own I/O: parsing arguments, printing lines, and choosing the exit code. (The Joomla commands still carry their own copies of that logic from before the core methods existed; making them call the core is a mechanical follow-up, and until it happens the Joomla text is the one that can drift.)
Comma-separated where Joomla repeats. WP-CLI assoc options cannot repeat, so the two repeatable Joomla options take comma-separated lists here:
--scope=catalog:write,orders:read(token-mint) and--format=openapi,postman(api-export). Same values, same validation, same exit codes.
generate-types,strings-purgeand the MCP--callworker boot withCB_SUPPRESS_AUTO_UPDATES(defined at plugin load, keyed off the invoked subcommand — same mechanism as the migrate commands below): the Joomla equivalents passbootKenedo(false)for the same reason.
strings-purgeexits 0 whether or not it finds orphans, matchingStringsPurgeCommand— orphaned translation rows are housekeeping, not a broken install, so this one must not gate a deploy the waycharset --statusandmigrate --statusdeliberately do. It gained the WordPress wrapper on 2026-08-08; before that the suite was one command short of the parity this document claims.
CB_SUPPRESS_AUTO_UPDATESis defined at plugin load, not in the command. On Joomla/Magento the migrate commands passbootKenedo(false)themselves — but under WP-CLI, WordPress fires theinithook (and with itconfigbox_init_kenedo()) before any command method runs, so a constant defined inside the command would come too late and--statuswould apply (or crash on) the very migrations it was asked to report on. The registration block inconfigbox.phptherefore inspectsWP_CLI::get_runner()->argumentsat plugin load and defines the constant when the invoked subcommand ismigrate,migrate-unblockorcharset. Same observer-side mechanism as the other hosts. (charsetis in that list for the same reason:charset --statusis a read-only report, and neither form of the command should drag pending migrations in as a side effect of being run.)
migrate-unblockconfirms interactively; pass--yes(the WP-CLI convention, instead of Joomla's-n) to proceed without a prompt.
Bootstrap is simpler than Joomla's. WP-CLI loads WordPress fully before the command runs, so
ABSPATH is defined and add_action() exists — exactly what KenedoPlatform::determineName() checks to
select the wordpress platform. The wrapper's bootKenedo() is just configbox_init_kenedo() (the same
function the plugin calls on the init hook); there is no SiteApplication-equivalent juggling. Do not
mark the commands @when before_wp_load — Kenedo's platform detection needs WordPress loaded.
Two WordPress-specific gotchas (both handled):
--useris reserved by WP-CLI as a global flag ("set the WordPress user"); it intercepts the value before a command sees it and validates it against WordPress users. So the CBX acting-user selector is exposed as--cb-useron WordPress (not--useras on Joomla). It accepts the same forms — e-mail, CBX user id,platform:<wp-user-id>(andwp:/wordpress:aliases) — via the sharedConfigboxCliHelper::resolveUserId().- Run WP-CLI under the site's PHP. The
wplauncher uses#!/usr/bin/env php; if the shell's defaultphpis older than the site requires (e.g. system PHP 7.4 vs the site's 8.1+), WordPress fails to boot. Invoke the phar with the right binary, e.g./path/to/php8.x wp configbox …, or setWP_CLI_PHP.
Asset URLs in run-task output — and the base URL in api-export — are correct on WordPress (the
platform's getUrlBase() reads get_site_url(), i.e. the DB-backed siteurl option, so it works
under WP-CLI too), unlike the Joomla console's joomla.invalid placeholder — no --live-site
equivalent is needed.
5.2 Magento (bin/magento) — implemented
The Magento 2 host ships a working bin/magento wrapper — the full suite of §4, including
configbox:migrate --status, configbox:migrate:unblock, the token:* family, api:export,
generate-types, mcp and chatadvisor:analyze. The commands live in the M2 module repo
(rovexo/cbx-magento, mounted at app/code/Rovexo/Configbox in the cbx-magento dev site), not in
the shared library — the submodule only carries the core (ConfigboxCliHelper).
Console/AbstractConfigboxCommand.php ← bootKenedo($applyUpdates = true) shared bootstrap + renderLines()
Console/ClearCacheCommand.php … TokenRevokeCommand.php ← one thin class per command (namespace Rovexo\Configbox\Console)
etc/di.xml ← registration: each command added to Magento\Framework\Console\CommandListInterface
Usage mirrors the Joomla suite one-for-one — same command names, arguments and options, just invoked
through bin/magento:
bin/magento configbox:cache:clear
bin/magento configbox:charset [--status]
bin/magento configbox:migrate [--status] [--clear-failed-flag]
bin/magento configbox:migrate:unblock [--dry-run] [--skip-version=<v>] [--track=<core|customization>]
bin/magento configbox:run-task <controller> [<task>] [<param>...] [-u|--user=<selector>] [--output-mode=<mode>]
bin/magento configbox:config:get|set|list …
bin/magento configbox:sysvar:get|set|list …
bin/magento configbox:generate-types [--check] [--schema-drift]
bin/magento configbox:token:mint <name> [-s|--scope=<area>:<level>] [-p|--preset=…] [--expires=…] # token:list / token:revoke likewise
bin/magento configbox:api:export [-d|--dir=…] [-f|--format=…] [--fragment]
bin/magento configbox:mcp [-s|--scope=<area>:<level>]... [-p|--preset=…] [--read-only] [--in-process]
bin/magento configbox:chatadvisor:analyze [--idle-minutes=<n>] [--max=<n>]
bin/magento configbox:product:export [-l|--list] [--sku=<sku>] [--id=<id>] [-o|--out=<path>]
bin/magento configbox:product:import <package> [--mode=<new|exact>] [--new-ids] [-f|--force] [--dry-run]
Host specifics (all handled in AbstractConfigboxCommand::bootKenedo()):
- Registration is plain
di.xml(Magento\Framework\Console\CommandListInterface) — unlike Joomla there is no plugin to install/enable; the commands appear as soon as the module is enabled (clear theconfigcache after adding them:bin/magento cache:clean config). - Magento instantiates every registered command on each
bin/magentocall, so the command constructors stay default and all bootstrapping happens insideexecute()viabootKenedo(). - Area code:
bin/magentosets no area, and Magento'sState::getAreaCode()throws until one is set — the platform adapter'sisAdminArea()/isSiteArea()and any front-end rendering hit it.bootKenedo()setsfrontend(guarded — an area set by the surrounding command context is kept). - Kenedo boot is the same
Rovexo_Configbox_KenedoLoaderthe module's observer andSetup/Recurringuse; platform detection keys onclass_exists('\Magento\Framework\App\Bootstrap'), which is autoloadable underbin/magento. The migrate commands passbootKenedo(false), which definesCB_SUPPRESS_AUTO_UPDATESbefore boot — same reason and mechanism as Joomla'sbootKenedo(false)(§2). - The product transfer commands need
bin/magento cache:clean configonce after the module is updated, like any newdi.xmlentry - until thenbin/magentodoes not know the names. Their logic is entirely inConfigboxCliHelper, so Magento's classes are argument parsing, printing and the exit code, and the rule about when an import may run is decided once for all three hosts and MCP. --useris fine on Magento —bin/magentoreserves no such option (unlike WP-CLI), so the acting-user selector keeps the Joomla spelling-u|--user, incl.platform:<magento-customer-id>/magento:<id>via the sharedresolveUserId().- Asset URLs in
run-taskoutput are correct (Magento has a real base URL) — no--live-siteequivalent is needed. The same goes forapi:export: the platform adapter'sgetUrlBase()reads the store's configured base URL, so the exported OpenAPI/Postman/HTML carry the real host under CLI with no extra flag (verified — no Joomla-stylejoomla.invalidplaceholder, and no host+script-path gluing). mcp's worker re-invocation builds its command from the running process (PHP_BINARY+SCRIPT_FILENAME, falling back toBP . '/bin/magento') and runs workers fromBP— the Magento equivalent of the Joomla wrapper'sJPATH_ROOTanchoring.ConfigboxCliHelperis only autoloadable afterbootKenedo()— boot before touching any shared glue (parseKeyValueParams()included; the run-task command boots first for exactly this reason).
6. Custom commands — the customization layer's custom namespace
Integrators can add site-specific commands to the suite without touching core. One file in the
upgrade-safe customization layer — <customization dir>/cli/commands.php, i.e.
data/customization/cli/commands.php on Joomla,
wp-content/plugins/configbox-customization/cli/commands.php on WordPress, and the
Rovexo_ConfigboxCustomizations module's view/customizations/cli/commands.php on Magento 2
(locations: customization overview §1.1) — returns an array of command definitions, and each
surfaces under the reserved custom namespace next to the stock commands:
wp configbox custom # list the custom commands installed on this site
wp configbox custom order-export 30 --format=csv
The full contract (definition fields, callback interface, worked example, conventions) is the
customization track's guide: ../customization/com_configbox_custom_cli_commands.md. This section
covers what belongs in the technical reference — the architecture and its one deliberate design
decision.
Split, same as the stock suite (§2): the contract, loader/validator and runner are shared core —
ConfigboxCliHelper::getCustomCliCommandsPath() (resolves against
KenedoPlatform::p()->getDirCustomization()), loadCustomCliCommands() (validates the file's return
against the contract, throwing precise messages on violations), and executeCustomCliCommand()
(normalizes the callback's return into an exit code; null counts as 0). Each host adds only a thin
dispatcher command, and all three hosts now ship one: ConfigboxCliCommand::custom() in the
cbx-wordpress adapter's cli.php, CustomCommand in the Joomla component's src/Console/
(registered by the console plugin), and Rovexo\Configbox\Console\CustomCommand in the Magento
module (registered in etc/di.xml). The same commands file surfaces on each unchanged.
The command is
configbox:custom <name>, not theconfigbox:custom:<name>an earlier draft of this section predicted — but that is a consequence of the lazy-dispatch decision below, not a technical impossibility. A console asks for its command list at startup, and the host is free to answer that question by reading the site'scommands.phpand declaring one console command per entry. Nothing prevents it: resolving the customization dir is plain path work on every host (no Kenedo boot, no DB), and a broken file is containable there too, because a PHP parse error in anincludeis a catchableParseError— so a guarded registration-time load does not take the stock suite down either. What registration really costs is that the integrator's arbitrary PHP then runs on every CLI invocation —migrate,cron,setup:upgrade— instead of only when someone asks for a custom command. That blast radius, not the parse error, is the reason to dispatch lazily. Declaring the commands is a legitimate alternative with a real benefit (they appear inlistand inhelp), and the two can coexist: declare them when the file loads cleanly, and keep the bare dispatcher as the always-available fallback.
Undeclared options are the one thing a wrapper has to solve. The callback owns its option vocabulary, so the dispatcher cannot declare it, and both Symfony-based consoles refuse an option their definition does not know. Magento calls
ignoreValidationErrors(); Joomla'sAbstractCommandis not a SymfonyCommandand has no such method, so its wrapper overridesexecute()and tolerates thebind()refusal. Both then read the real arguments offargv, splitting on--and--key=value, which is also what makes--work as an end-of-options marker.
The design decision: dispatch lazily, never register. Custom commands are not registered as
first-class console commands at startup. Only the stock custom dispatcher is registered; the
customization file is loaded when — and only when — the dispatcher actually runs, inside a
catch (Throwable). Rationale:
- Stock commands cannot be taken down by customization code. Registration-time loading would
execute the integrator's file on every CLI invocation — a parse error there would kill
wp configbox migrate, i.e. exactly the recovery tooling you need when a site is broken. With lazy dispatch, a throwing, contract-violating or even unparsablecommands.phpbreaks onlywp configbox custom …, and the dispatcher reports the file and the error cleanly. (PHP parse errors areThrowablesince PHP 7, so even those are contained.) - A crashing custom command is contained to itself: the dispatcher wraps execution separately and
reports
Custom command "<name>" failed: <message>with a non-zero exit. - Cost: custom commands don't appear in
wp help configboxas individual subcommands — the listing (wp configbox customwith no argument, showing each command'ssynopsisandshortdesc) is the discovery surface instead. That trade is the point.
Exit codes pass through (WP_CLI::halt($code)), so cron and deploy scripts can gate on custom
commands exactly like on stock ones.
7. See also
../../components/com_configbox/helpers/cli.php— the reusable core, fully documented inline (shared submodule; the same file backs the Joomla, WordPress and Magento wrappers).- The WordPress wrapper (in the
cbx-wordpressrepo):docroot/wp-content/plugins/configbox/cli.php(ConfigboxCliCommand) + its registration indocroot/wp-content/plugins/configbox/configbox.php. - The Magento wrapper (in the
rovexo/cbx-magentomodule repo):Console/*.php(Rovexo\Configbox\Console\*) + its registration inetc/di.xml. ../customization/com_configbox_custom_cli_commands.md— adding site-specific commands under thecustomnamespace (§6): the contract, a worked example, and the conventions.com_configbox_migrations.md— the migration system themigratecommand drives.- The
/cb-api/endpoint (plugins/system/configbox/configbox.php+components/com_configbox/configbox.php) — the HTTP pathrun-taskmirrors.