Skip to main content
Version: 4.0 preview

CBX CLI command suite

Version
4.0 preview
Updated
View markdown

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 ConfigboxCliHelper method: update §1 (the command's section), §4 (the reference table), §2 (if the core/wrapper split changed), and the command's own setHelp() text. A repo hook reminds about this when files under src/Console/ or helpers/cli.php are 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 --status and configbox: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:):

CommandWhat it does
configbox:cache:clearClear all CBX caches.
configbox:migrateApply outstanding CBX migration (update) scripts (--status to only report).
configbox:migrate:unblockLift the freeze a failed migration puts on the update system — explained, confirmed, logged.
configbox:charsetConvert CBX tables to the recommended character set / collation (utf8mb4_unicode_ci); --status to only report.
configbox:run-taskRun a CBX controller task from the CLI, exactly as the /cb-api/ endpoint runs it over HTTP.
configbox:mcpRun 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:getRead a CBX setting value.
configbox:config:setSet a CBX setting, validated exactly like a UI save.
configbox:config:listList all settings with type, editability and current value.
configbox:sysvar:getRead a value from the low-level system-vars store.
configbox:sysvar:setWrite a value to the system-vars store.
configbox:sysvar:listList all system-vars.
configbox:generate-typesRegenerate the typed record classes and JSON schemas from the models' property definitions (--check to only verify).
configbox:strings:purgeRemove translation rows whose record no longer exists (--status to only report).
configbox:token:mintIssue a bearer token for the REST API at /cb-api/v1/. Shown once.
configbox:token:listList the API tokens with scopes, expiry and last use. No secrets.
configbox:token:revokeTurn one API token off.
configbox:token:scopesPrint the scope vocabulary — areas, the twelve scopes, presets, rules — from the code that enforces it. --json for scripts.
configbox:api:exportWrite the API description as OpenAPI, a Postman collection and an HTML reference.
configbox:chatadvisor:analyzeWrite lessons-learned reviews for idle, unanalyzed chat advisor conversations (cron-able).
configbox:chatadvisor:relaySend vendor-stakeholder recommendations to the vendor's collection endpoint (opt-in; --dry-run previews the envelope).
configbox:payments:pollRe-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. The sysvar:* commands manage the low-level internal bookkeeping store (#__configbox_system_vars: update version, the failed-update flag, last cleanup time). Use config:* for configuration; reach for sysvar:* 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:clean and 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, which plg_system_configbox handles. (Joomla core has no cache CLI command; on the CLI use configbox:cache:clear.)
  • WordPress: wp cache flush (WP-CLI) — the plugin hooks after_invoke:cache flush and 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
OptionEffect
--statusReport 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-flagClear a stuck failed_update_detected flag from a prior failed run before applying updates.
-vOn 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 onConfigboxInitialized observer). 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 with CB_SUPPRESS_AUTO_UPDATES defined so their own bootstrap does not apply migrations behind their back — without that, --status would 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
OptionEffect
--dry-runReport 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

ArgumentMeaning
controllerThe Kenedo controller name (required), e.g. cart, configuratorpage, testautomation.
taskThe task = a public method on the controller (default: display).
param...Any number of key=value request parameters — the GET/POST equivalents, space-separated.

Options

OptionMeaning
-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-site option (or set a real live_site in configuration.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:

EditableSetting types
yesstring, boolean, dropdown, join (scalar settings), and translatable (per language).
nogroupstart/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 join settings (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 flipping failed_update_detected) changes how CBX behaves on the next init. sysvar:set is for support and recovery — not routine use. For the migration vars specifically, prefer the purpose-built commands: configbox:migrate:unblock clears failed_update_detected (plus the recorded failure details, with an explanation and an audit log line), and its --skip-version moves latest_update_version safely (format-checked, confirmed). A raw sysvar:set does 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

--check is 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 only en-US against artifacts generated with en-GB+de-DE) fails --check by 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, writecatalog: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
FileWhat it is
openapi.jsonOpenAPI 3.1, every schema inlined. Import into Postman, Insomnia, Bruno, Swagger UI, Redoc, or a client generator.
configbox.postman_collection.jsonPostman 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.htmlOne 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 independentcatalog: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]
OptionEffect
--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]
OptionEffect
--dry-runPrint 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:

LevelOn the CLI
blockerAlways refuses. No flag overrides it, --force included.
warningRefuses unless accepted deliberately: interactively by answering the prompt, non-interactively with --force.
noticePrinted, 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: trueMCP 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*Result helpers 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 two migrate* commands pass false, which defines CB_SUPPRESS_AUTO_UPDATES before boot so the observer skips that — they drive applyUpdates() explicitly instead. Without this, --status would 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) because ConfigboxUpdateHelper does 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

CommandArgumentsOptionsCore method
configbox:cache:clearConfigboxCliHelper::clearCache()
configbox:migrate--status, --clear-failed-flagConfigboxCliHelper::applyMigrations() / getMigrationStatus()
configbox:migrate:unblock--dry-run, --skip-version=<v>, --track=<core|customization>ConfigboxCliHelper::unblockMigrations()
configbox:charset--statusConfigboxCliHelper::convertCharset() / getCharsetStatus()
configbox:run-taskcontroller, [task=display], [param...]-u|--user, --output-modeConfigboxCliHelper::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:getkey-l|--languageConfigboxCliHelper::getConfig()
configbox:config:setkey, value-l|--languageConfigboxCliHelper::setConfig()
configbox:config:list-l|--language, --editable-onlyConfigboxCliHelper::listConfig()
configbox:sysvar:getkeyConfigboxCliHelper::getSystemVar()
configbox:sysvar:setkey, valueConfigboxCliHelper::setSystemVar()
configbox:sysvar:listConfigboxCliHelper::listSystemVars()
configbox:generate-types--check, --schema-driftConfigboxCliHelper::generateTypes() / checkTypes() / reportTypeSchemaDrift()
configbox:strings:purge--statusConfigboxCliHelper::getOrphanedStringStatus() / purgeOrphanedStrings()
configbox:token:mintname-s|--scope (repeatable, <area>:<level>), -p|--preset, --expiresConfigboxApiTokenHelper::mint()
configbox:token:listConfigboxApiTokenHelper::getTokens()
configbox:token:revokeidConfigboxApiTokenHelper::revoke()
configbox:token:scopes--jsonConfigboxApiTokenHelper::getAreas() / getKnownScopes() / getPresets()
configbox:api:export-d|--dir, -f|--format (repeatable), --fragmentConfigboxOpenApiHelper::buildDocument() / ConfigboxPostmanHelper::buildCollection() / ConfigboxApiHtmlHelper::render()
configbox:chatadvisor:analyze--idle-minutes=<n>, --max=<n>ConfigboxChatAdvisorHelper::analyzeIdleConversations()
configbox:chatadvisor:relay--dry-runConfigboxChatVendorRelayHelper::relayPending()
configbox:payments:poll--minutes=<n>, --limit=<n>ConfigboxPaymentService::pollPending()
configbox:product:export--sku (repeatable), --id (repeatable), -o|--out, --listConfigboxProductTransferHelper::exportProducts()
configbox:product:importpackage--mode=<new|exact>, --new-ids, -f|--force, --dry-runConfigboxProductTransferValidationHelper::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() and describeTransferFailure() are all ConfigboxCliHelper methods, 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-purge and the MCP --call worker boot with CB_SUPPRESS_AUTO_UPDATES (defined at plugin load, keyed off the invoked subcommand — same mechanism as the migrate commands below): the Joomla equivalents pass bootKenedo(false) for the same reason.

strings-purge exits 0 whether or not it finds orphans, matching StringsPurgeCommand — orphaned translation rows are housekeeping, not a broken install, so this one must not gate a deploy the way charset --status and migrate --status deliberately 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_UPDATES is defined at plugin load, not in the command. On Joomla/Magento the migrate commands pass bootKenedo(false) themselves — but under WP-CLI, WordPress fires the init hook (and with it configbox_init_kenedo()) before any command method runs, so a constant defined inside the command would come too late and --status would apply (or crash on) the very migrations it was asked to report on. The registration block in configbox.php therefore inspects WP_CLI::get_runner()->arguments at plugin load and defines the constant when the invoked subcommand is migrate, migrate-unblock or charset. Same observer-side mechanism as the other hosts. (charset is in that list for the same reason: charset --status is a read-only report, and neither form of the command should drag pending migrations in as a side effect of being run.)

migrate-unblock confirms 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):

  • --user is 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-user on WordPress (not --user as on Joomla). It accepts the same forms — e-mail, CBX user id, platform:<wp-user-id> (and wp: / wordpress: aliases) — via the shared ConfigboxCliHelper::resolveUserId().
  • Run WP-CLI under the site's PHP. The wp launcher uses #!/usr/bin/env php; if the shell's default php is 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 set WP_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 the config cache after adding them: bin/magento cache:clean config).
  • Magento instantiates every registered command on each bin/magento call, so the command constructors stay default and all bootstrapping happens inside execute() via bootKenedo().
  • Area code: bin/magento sets no area, and Magento's State::getAreaCode() throws until one is set — the platform adapter's isAdminArea()/isSiteArea() and any front-end rendering hit it. bootKenedo() sets frontend (guarded — an area set by the surrounding command context is kept).
  • Kenedo boot is the same Rovexo_Configbox_KenedoLoader the module's observer and Setup/Recurring use; platform detection keys on class_exists('\Magento\Framework\App\Bootstrap'), which is autoloadable under bin/magento. The migrate commands pass bootKenedo(false), which defines CB_SUPPRESS_AUTO_UPDATES before boot — same reason and mechanism as Joomla's bootKenedo(false) (§2).
  • The product transfer commands need bin/magento cache:clean config once after the module is updated, like any new di.xml entry - until then bin/magento does not know the names. Their logic is entirely in ConfigboxCliHelper, 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.
  • --user is fine on Magentobin/magento reserves 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 shared resolveUserId().
  • Asset URLs in run-task output are correct (Magento has a real base URL) — no --live-site equivalent is needed. The same goes for api:export: the platform adapter's getUrlBase() 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-style joomla.invalid placeholder, and no host+script-path gluing).
  • mcp's worker re-invocation builds its command from the running process (PHP_BINARY + SCRIPT_FILENAME, falling back to BP . '/bin/magento') and runs workers from BP — the Magento equivalent of the Joomla wrapper's JPATH_ROOT anchoring.
  • ConfigboxCliHelper is only autoloadable after bootKenedo() — 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 the configbox: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's commands.php and 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 an include is a catchable ParseError — 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 in list and in help), 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's AbstractCommand is not a Symfony Command and has no such method, so its wrapper overrides execute() and tolerates the bind() refusal. Both then read the real arguments off argv, 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 unparsable commands.php breaks only wp configbox custom …, and the dispatcher reports the file and the error cleanly. (PHP parse errors are Throwable since 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 configbox as individual subcommands — the listing (wp configbox custom with no argument, showing each command's synopsis and shortdesc) 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-wordpress repo): docroot/wp-content/plugins/configbox/cli.php (ConfigboxCliCommand) + its registration in docroot/wp-content/plugins/configbox/configbox.php.
  • The Magento wrapper (in the rovexo/cbx-magento module repo): Console/*.php (Rovexo\Configbox\Console\*) + its registration in etc/di.xml.
  • ../customization/com_configbox_custom_cli_commands.md — adding site-specific commands under the custom namespace (§6): the contract, a worked example, and the conventions.
  • com_configbox_migrations.md — the migration system the migrate command drives.
  • The /cb-api/ endpoint (plugins/system/configbox/configbox.php + components/com_configbox/configbox.php) — the HTTP path run-task mirrors.