Adding CLI commands to Admidio modules
Admidio provides a command-line task registry that allows modules to expose additional commands through the top-level admidio utility.
The CLI is intentionally lightweight. It is not a second application framework. A CLI command should normally be a thin adapter around the same Admidio Entity, Service or ValueObject classes used by the web module.
This page describes how an Admidio module can register its own CLI commands.
For administrator documentation see Using the Admidio CLI and the CLI command reference.
Current limitation: CLI command metadata is not localized yet. Generated command help and the generated DokuWiki command reference are therefore currently English-only. Descriptions, usage text, argument descriptions, option descriptions and examples registered by a module should currently be written in English.
Architecture
The CLI is implemented primarily by:
Admidio\Infrastructure\Cli\CliApplication Admidio\Infrastructure\Cli\CliTaskRegistry Admidio\Infrastructure\Cli\CoreTasks
Core commands and module commands are dispatched through the same registry.
The basic startup sequence for an installed Admidio instance is:
admidio
|
+-- CLI bootstrap
|
+-- register Admidio core commands
|
+-- load modules/*/cli.php
|
+-- identify command
|
+-- parse and validate arguments/options
|
+-- load the acting user when required
|
+-- check declared component/additional rights
|
+-- execute the registered callback
|
+-- flush queued session reload state
There is no separate CLI controller, repository layer or console framework.
The CLI owns command-line concerns:
- command discovery and parsing;
- input metadata validation;
- acting-user initialization;
- declared component-access checks;
- help/documentation rendering;
- common output formats;
- confirmations and stdin secret input;
- exception-to-exit-code handling.
The actual domain operation should remain in the same Entity/Service code used by the web application.
Module discovery
A module can provide the optional file:
modules/<module>/cli.php
The CLI scans the module directories for these files after the Admidio CLI bootstrap and after core commands have been registered.
For example:
modules/example/cli.php
The file should only register commands.
Do not execute the task itself while cli.php is being included.
In particular, do not:
- change database data at file scope;
- write normal command output at file scope;
- require an acting user at file scope;
- depend on the selected command having already been executed.
The acting user is initialized after command parsing and before the selected callback is invoked.
Module registration is isolated. If one module's cli.php throws while being loaded, the CLI writes a warning to standard error and continues loading the remaining module registrations. A broken module should therefore not make admidio help unusable for every other command.
Minimal command
A command is registered through:
<?php use Admidio\Infrastructure\Cli\CliApplication; use Admidio\Infrastructure\Cli\CliTaskRegistry; CliTaskRegistry::register( 'example:rebuild', 'EXAMPLE', static function (array $arguments, array $options): int { // Call the module's existing Entity/Service operation here. CliApplication::writeSuccess( 'Example data rebuilt.', $options ); return CliApplication::EXIT_SUCCESS; }, 'Rebuild data maintained by the example module.' );
This registers:
admidio example:rebuild
The second parameter is the existing Admidio component used for the normal permission check.
Module tasks registered through CliTaskRegistry::register() always require an acting user. The CLI therefore expects:
./admidio example:rebuild --as=administrator
By default, before invoking the callback the CLI checks:
Component::isAdministrable($componentName)
A module command should use the component that already represents administration of that module.
Do not invent a separate CLI permission system or a CLI-only component.
Registration signature
The current module registration method is:
CliTaskRegistry::register( string $taskName, string $componentName, callable $callback, string $description = '', string $usage = '', array $arguments = array(), array $options = array(), array $examples = array(), string $componentAccess = CliTaskRegistry::ACCESS_ADMINISTRABLE );
| Parameter | Description |
|---|---|
$taskName | Command name in namespace:task form. |
$componentName | Existing Admidio component used for the component-access check. |
$callback | Callable executed for the selected command. |
$description | Short English description shown by list and help. |
$usage | Command-specific usage syntax without the leading admidio. |
$arguments | Structured positional argument definitions. |
$options | Structured long-option definitions. |
$examples | Example command lines displayed in help. |
$componentAccess | ACCESS_ADMINISTRABLE by default; ACCESS_VISIBLE for an appropriate read-only command. |
The metadata is not only documentation. The CLI also uses it to validate command input, generate help, generate shell completion and generate the command reference.
Command naming and namespace ownership
Module command names must use one namespace separator:
module:task
The registry accepts lowercase letters, digits and hyphens.
For a module loaded from:
modules/example/cli.php
the command namespace must be:
example:
If the module directory ends in s, the registry also accepts the form with the final s removed. For example:
modules/events/
may own:
events: event:
This is a literal final-s rule implemented by the registry; it is not a general linguistic singularization mechanism.
Valid names for corresponding module directories include:
example:rebuild inventory-sync:run my-module:import
Invalid command names include:
Example:Rebuild example:data:rebuild
Core namespaces are reserved as soon as core commands are registered.
A module cannot register a command in a namespace already owned by Admidio core and cannot extend or override a core namespace.
For example, an external module must not try to register:
user:custom-task
Duplicate command names are rejected.
Arguments
Positional arguments are described using arrays.
Example:
$arguments = array( array( 'name' => 'item', 'description' => 'UUID of the item to process.', 'required' => true, 'multiple' => false ) );
The fields are:
| Key | Meaning |
|---|---|
name | Argument name displayed in help. Required. |
description | Human-readable English description. |
required | Whether the argument must be supplied. |
multiple | Whether this argument consumes all remaining positional values. |
A repeatable positional argument must therefore be the last positional argument.
Required arguments should precede optional positional arguments.
Example registration:
CliTaskRegistry::register( 'example:show', 'EXAMPLE', static function (array $arguments, array $options): int { $item = CliApplication::requireArgument( $arguments, 0, 'item' ); CliApplication::writeValue( array('item' => $item), $options, 'record' ); return CliApplication::EXIT_SUCCESS; }, 'Show an example object.', 'example:show ITEM [--format=record|json]', array( array( 'name' => 'item', 'description' => 'Example item UUID.', 'required' => true, 'multiple' => false ) ), array( array( 'name' => 'format', 'description' => 'Output format.', 'value' => 'FORMAT', 'required' => false, 'multiple' => false, 'flag' => false, 'values' => array('record', 'json') ) ) );
The argument is validated for presence before the callback is called.
Options
Command options are long options beginning with –.
An option that takes a value can be supplied as either:
--date=2030-07-01 --date 2030-07-01
Example option definition:
$options = array( array( 'name' => 'date', 'description' => 'Processing date.', 'value' => 'DATE', 'required' => false, 'multiple' => false, 'flag' => false ) );
| Key | Meaning |
|---|---|
name | Option name without –. Required. |
description | Human-readable English description. |
value | Name used for the value in generated help, for example DATE. |
required | Whether the option is required. |
multiple | Whether it may be supplied more than once. |
flag | true if the option has no value. |
values | Optional finite list of accepted string values. The CLI validates them automatically. |
A flag looks like:
array( 'name' => 'recursive', 'description' => 'Process child objects recursively.', 'required' => false, 'multiple' => false, 'flag' => true )
An option with allowed values can be defined as:
array( 'name' => 'format', 'description' => 'Output format.', 'value' => 'FORMAT', 'required' => false, 'multiple' => false, 'flag' => false, 'values' => array( 'table', 'record', 'json', 'csv', 'md', 'dokuwiki' ) )
The possible values are included in generated help and invalid values are rejected before the callback is called.
Global option names
Every command already understands the global CLI options:
--host --organization --as --format --output --quiet --no-interaction --yes --help
The short global flags are:
-h -q -y
Do not define a module-specific option that shadows a global option.
format, output and yes may be repeated in task metadata when necessary to document the task's supported values or semantics. cli:selfcheck treats other global-option shadowing as a registry problem.
Component access: administrable versus visible
The default module registration uses:
CliTaskRegistry::ACCESS_ADMINISTRABLE
This makes the CLI call:
Component::isAdministrable($componentName)
before the callback.
This is the correct default for commands that change module data or configuration.
For a read-only command that should be available wherever the corresponding web component is visible, register:
CliTaskRegistry::register( 'example:list', 'EXAMPLE', static function (array $arguments, array $options): int { // Read through the module's normal Entity/Service API and keep record-level visibility checks. CliApplication::writeRows( array(), CliApplication::optionString($options, 'format', 'table'), $options ); return CliApplication::EXIT_SUCCESS; }, 'List visible example objects.', 'example:list [--format=table|json]', array(), array( array( 'name' => 'format', 'description' => 'Output format.', 'value' => 'FORMAT', 'required' => false, 'multiple' => false, 'flag' => false, 'values' => array('table', 'json') ) ), array(), CliTaskRegistry::ACCESS_VISIBLE );
This makes the CLI call:
Component::isVisible($componentName)
instead.
Important: ACCESS_VISIBLE is only the component-level gate. The callback or reused domain code must still apply the same record-level visibility/permission checks as the web module.
Do not use ACCESS_VISIBLE merely to make a mutating command easier to call. For a write operation, keep ACCESS_ADMINISTRABLE unless the reused domain service already implements the complete normal rights model for that operation.
Reading arguments and options
CliApplication provides typed helpers for common CLI input handling.
Required positional argument
$item = CliApplication::requireArgument( $arguments, 0, 'item' );
The registry already validates required argument metadata, but requireArgument() is still useful in the callback to retrieve the named value and produce a consistent error if the callback is reused directly.
String option
$mode = CliApplication::optionString( $options, 'mode', 'default' );
Repeated option
For:
--group=Board --group=Members
define the option with multiple ⇒ true and use:
$groups = CliApplication::optionValues( $options, 'group' );
Test whether an option was supplied
When “not supplied” is different from “supplied with an empty/false value”, use:
if (CliApplication::optionExists($options, 'leader')) { // ... }
Boolean option
$recursive = CliApplication::optionBool( $options, 'recursive', false );
Boolean values accepted by the helper include:
1 / 0 true / false yes / no on / off
An invalid boolean value throws InvalidArgumentException.
Integer option
$limit = CliApplication::optionInt( $options, 'limit', 100 );
Non-integer input is rejected.
Floating-point option
$ratio = CliApplication::optionFloat( $options, 'ratio' );
Non-numeric input is rejected.
Date/time validation
The public CLI date/time helper is:
$timestamp = CliApplication::validateDateTime( CliApplication::optionString($options, 'at'), 'at' );
The canonical CLI form is:
YYYY-MM-DDTHH:MM
The helper also accepts the equivalent space-separated form, with optional seconds, and returns:
YYYY-MM-DD HH:MM:SS
There is currently no public CliApplication::validateDate() helper. Do not copy documentation or old examples that call that nonexistent method. Date-only values should be validated by the existing module/domain operation, or by the same validation pattern already used by the corresponding Admidio code.
Resolving users and object ids
When a module needs to resolve a normal CLI user selector, the CLI exposes:
$user = CliApplication::resolveUser($reference);
The normal lookup is scoped to the current organization.
For a table-backed UUID/id selector, the CLI also provides the generic selector helper:
public static function resolveId( string $table, string $idColumn, string $uuidColumn, string $reference, string $label, string $additionalWhere = '', array $additionalParams = array() ): int
Pass the module's existing table constant and real id/UUID column names. resolveId() is only a selector helper. After resolving the id, use the normal Entity/Service for the actual domain operation.
If the existing module already has a better resolver that enforces additional organization/visibility rules, reuse that instead.
Confirmation and non-interactive operation
For an operation that requires explicit confirmation, use:
CliApplication::confirm( 'Delete the example item?', $options );
The helper behaves consistently with the global options:
–yesaccepts the confirmation;- without
–yes, interactive execution asks[y/N]; –no-interactionwithout–yesfails rather than silently proceeding.
Do not implement a second confirmation parser inside the module callback.
Reading secrets
When a command accepts a secret, provide a normal option and an stdin flag, for example:
--password=PASSWORD --password-stdin
Then read it through:
$password = CliApplication::readSecret( $options, 'password', 'password-stdin' );
This keeps secret input out of shell history when the stdin form is used.
On supported Unix-like interactive terminals the helper disables terminal echo while reading from stdin.
Producing output
Do not build custom format handling in every command.
Use the CLI output helpers so global output behavior and machine-readable formats stay consistent.
Single scalar or record
CliApplication::writeValue( $data, $options, 'record' );
For JSON, writeValue() emits structured JSON.
For an associative array in record mode it produces a field/value representation.
List of records
CliApplication::writeRows( $rows, CliApplication::optionString( $options, 'format', 'table' ), $options );
writeRows() supports the normal row formats:
table record json csv md dokuwiki
text is treated as table output for rows.
The helper aligns heterogeneous rows to the union of their keys before rendering.
Success message
For a mutating command that has no result data beyond confirmation:
CliApplication::writeSuccess( 'Item updated.', $options );
This:
- suppresses the human success message when
–quietis active; - emits a structured success object when
–format=jsonis requested.
Do not replace it with echo.
Raw/generated output
If a command already has a generated document:
CliApplication::writeOutput( $content, $options );
By default this honors –output=FILE.
Prefer writeValue() or writeRows() for normal structured data.
Commands that generate files
When a command generates a file with a natural filename, use:
$target = CliApplication::resolveOutputPath( $options, $filename );
This supports:
- no
–output: use the current working directory; –outputpointing to a directory: append the natural filename;–outputpointing to a filename: use it directly.
For exports containing secrets, such as private keys or complete database dumps, use:
CliApplication::protectExportedFile($target);
after the file has been created.
Exit codes
Callbacks should normally return:
CliApplication::EXIT_SUCCESS
rather than a magic numeric 0.
The CLI defines:
| Constant | Code | Meaning |
|---|---|---|
CliApplication::EXIT_SUCCESS | 0 | Command completed successfully. |
CliApplication::EXIT_ERROR | 1 | Internal error, for example a database/PHP failure. |
CliApplication::EXIT_USAGE | 2 | Invalid command-line arguments/options. |
CliApplication::EXIT_STATE_NOT_OK | 3 | Check completed, but reported state is not OK. |
CliApplication::EXIT_UPDATE_AVAILABLE | 4 | Check completed and a newer Admidio release is available. |
CliApplication::EXIT_REJECTED | 5 | Admidio rejected the operation, for example permissions/domain validation. |
CliApplication::EXIT_FAILED | 6 | Valid/permitted operation could not be completed. |
Most ordinary module callbacks should either return EXIT_SUCCESS or throw an appropriate exception. Special non-zero result codes should only be returned when the command's semantics intentionally represent a successful check with a special state.
Exceptions and error output
Do not print an error and call exit() from a module callback.
Throw exceptions and let the top-level CLI map them to stderr and an exit code.
For Admidio domain errors, throw the same Admidio exception the web/domain operation normally throws, for example:
throw new Admidio\Infrastructure\Exception( 'SYS_NO_RIGHTS' );
For invalid CLI-specific input:
throw new InvalidArgumentException( '--date must use YYYY-MM-DD.' );
The top-level mapping distinguishes:
InvalidArgumentException→ exit2;Admidio\Infrastructure\Exception→ exit5;RuntimeException→ exit6;- database/PHP/internal errors → exit
1.
If the caller requested –format=json, CLI failures are written as structured JSON to standard error, including the message, exception type and exit code.
Permissions
Every module task registered through:
CliTaskRegistry::register(...)
specifies an existing Admidio component and requires an acting user.
Before the callback is run, the CLI:
- loads the user supplied through
–as; - requires that the account is activated and an active member of the current organization;
- establishes that user as the current Admidio user;
- checks the declared component access:
Component::isAdministrable()forACCESS_ADMINISTRABLE;Component::isVisible()forACCESS_VISIBLE.
This is only the first permission layer.
The callback or the service it calls must still perform any more specific domain checks required by the operation.
For example:
- a group membership operation must retain normal role-assignment permission checks;
- an object owned by an organization must remain restricted to the appropriate organization;
- a read-only command registered with
ACCESS_VISIBLEmust still enforce the same record visibility as the web module.
Do not assume that passing the component check automatically authorizes every operation inside the module.
Business logic belongs in services and entities
A CLI command should not become an alternative implementation of the module.
Bad:
CliTaskRegistry::register( 'example:delete', 'EXAMPLE', static function (array $arguments, array $options): int { global $gDb; // Large amount of custom SQL and duplicated business logic here... } );
Better:
CliTaskRegistry::register( 'example:delete', 'EXAMPLE', static function (array $arguments, array $options): int { global $gDb; $uuid = CliApplication::requireArgument( $arguments, 0, 'item' ); $entity = new Example($gDb); $entity->readDataByUuid($uuid); CliApplication::confirm( 'Delete this example item?', $options ); $entity->delete(); CliApplication::writeSuccess( 'Item deleted.', $options ); return CliApplication::EXIT_SUCCESS; } );
The example assumes Example is the module's existing Entity class. Do not create a CLI-specific Entity solely for the command.
If the operation contains non-trivial save logic, the preferred design is:
web form
|
+-- FormPresenter validation
|
+-- module service
|
+-- Entity / ValueObject / other native Admidio APIs
CLI
|
+-- CLI argument validation
|
+-- same module service
Do not emulate $_POST, a web session or FormPresenter objects from the CLI.
If an existing service currently obtains all its data directly from a FormPresenter, extract the reusable data-oriented operation into that same service and leave the existing web method as its FormPresenter adapter.
Database access
Module CLI commands follow the same database rules as the rest of Admidio.
Where an Entity exists, use it.
For direct queries use:
$gDb->queryPrepared(...)
Do not add a CLI-specific repository layer simply because the operation is invoked from the shell.
Organization-scoped data must remain scoped to the current organization.
If CliApplication::resolveId() is used to turn a UUID/id selector into an id, use the normal Entity/Service for the actual operation afterwards.
Changelog, origin and session state
The acting user selected through:
--as=USER
becomes the current Admidio user before the callback runs.
Normal Entity operations therefore continue to use the usual Admidio audit/changelog mechanisms.
The CLI also records an origin comment containing the command name, for example:
CLI: example:rebuild
Do not implement a separate CLI changelog.
Some changes require browser sessions to reload cached user/settings state. The CLI batches this through:
CliApplication::queueSessionReload();
or, for one user:
CliApplication::queueSessionReload($userId);
The application flushes the queued reload state after the callback, including when the callback throws.
Reuse an existing service's session-reload behavior when it already handles this. Do not add direct session-table updates to every CLI command.
Help is generated from the registry
Descriptions, arguments, options, allowed values and examples stored in the task registry are used to generate:
admidio help example:process
as well as:
admidio help --all --format=md admidio help --all --format=dokuwiki admidio help --all --format=json
Good metadata is therefore part of the user documentation.
A new command should include at least:
- a meaningful English description;
- a complete usage expression;
- descriptions for all arguments;
- descriptions for all options;
- allowed values where the set is finite;
- one or more realistic examples for non-trivial commands.
Do not maintain separate hard-coded help text in the command callback.
Because the CLI metadata is not localized yet, the generated DokuWiki command reference is currently English-only. This is a known limitation of the CLI, not a reason to maintain a second manually translated command catalogue.
Shell completion is generated from the registry
The registry is also used by:
admidio completion bash admidio completion zsh
A registered command contributes its name and declared options automatically.
Commands marked unavailable are omitted from completion.
This is another reason to keep option metadata complete instead of parsing undocumented ad-hoc switches inside the callback.
Testing a module command
After adding modules/example/cli.php, first verify discovery:
./admidio list example ./admidio module:tasks example
Then inspect the generated documentation:
./admidio help example:process
Also test a machine-readable format:
./admidio example:process ITEM_UUID \ --format=json \ --as=administrator
Run the CLI infrastructure self-check:
./admidio cli:selfcheck
cli:selfcheck validates registry consistency and generated help and also checks the core CLI source for several internal wiring errors. It does not replace behavioral tests of a module's domain operation.
For a mutating command test at least:
- permitted actor;
- actor without sufficient permissions;
- deactivated/non-member actor where relevant;
- invalid object identifier;
- missing required argument;
- unknown option;
- invalid finite option value;
- repeated non-repeatable option;
- non-interactive execution;
- confirmation behavior where applicable;
- JSON success/error behavior;
- normal web operation after any service extraction.
For a read-only ACCESS_VISIBLE command, test both component/record visibility and the negative case.
If the command modifies organization-owned data, also test the organization boundary.
If the command accepts secrets, test the stdin form without placing the secret in command history.
Adding commands to Admidio core
Core commands are not registered through a module cli.php file.
They are registered centrally in:
Admidio\Infrastructure\Cli\CoreTasks
CoreTasks uses its internal task(), readTask() and alias() helpers, which in turn use the core registry entry point.
External modules must use:
CliTaskRegistry::register(...)
and must not call registerCore().
Registering a core command reserves its namespace. External modules cannot extend or override a namespace already owned by Admidio core.
When adding a core command, follow the closest existing command in CoreTasks and reuse the corresponding Admidio Entity/Service implementation.
For a read-only core command, follow the readTask() pattern so component visibility and record-level rights remain aligned with the web feature.
Plugins
Admidio has core plugin:* administration commands. These are different from a plugin supplying its own CLI task.
Current implementation status: automatic CLI task discovery scans:
modules/*/cli.php
It does not automatically scan plugin directories.
Therefore a plugin cannot currently become CLI-enabled simply by adding its own cli.php file.
The underlying registration mechanism is generic once code has been loaded, but there is currently no defined plugin discovery/loading mechanism for plugin CLI registrations.
Do not document or rely on a path such as:
adm_plugins/<plugin>/cli.php
until such discovery exists in Admidio core.
If plugin CLI registration is implemented in the future, it should:
- discover only plugins known to the normal Admidio PluginManager;
- load registrations after CLI bootstrap;
- use the same registry and metadata rules;
- preserve duplicate-command, namespace-ownership and reserved-core-namespace checks;
- use the plugin's existing Admidio component/permission model;
- not execute task logic during discovery.
Checklist for new module CLI tasks
Before adding a new command, verify that:
- the command uses the module directory namespace (or its permitted final-
ssingular form); - the namespace is not reserved by Admidio core;
- the module already has an Admidio component representing its access model;
- a mutating command uses
ACCESS_ADMINISTRABLEunless the reused domain service implements the complete normal rights model; - a read-only
ACCESS_VISIBLEcommand retains record-level visibility checks; - all arguments and options are described in the registry;
- repeatable positional arguments come last;
- required positional arguments precede optional positional arguments;
- finite option values are declared in
values; - module options do not shadow global CLI options;
- the callback contains only CLI adaptation logic;
- existing Entities and Services are reused;
- no web session or FormPresenter is simulated;
- specific domain permission checks are retained;
- organization scope is retained;
- database access uses normal Entity or
queryPrepared()patterns; - secrets use stdin input where appropriate;
- confirmations use
CliApplication::confirm(); - output uses
CliApplicationhelpers; - errors are thrown instead of printed manually;
- success returns
CliApplication::EXIT_SUCCESS; - the command has useful examples;
help COMMANDis complete;help –all –format=dokuwikiproduces usable documentation;completionsees the expected command/options;cli:selfcheckpasses;- JSON output/error behavior is usable by scripts;
- the corresponding web functionality still works after any service extraction.