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.
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:
The actual domain operation should remain in the same Entity/Service code used by the web application.
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:
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.
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.
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.
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.
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.
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.
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.
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.
CliApplication provides typed helpers for common CLI input handling.
$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.
$mode = CliApplication::optionString( $options, 'mode', 'default' );
For:
--group=Board --group=Members
define the option with multiple ⇒ true and use:
$groups = CliApplication::optionValues( $options, 'group' );
When “not supplied” is different from “supplied with an empty/false value”, use:
if (CliApplication::optionExists($options, 'leader')) { // ... }
$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.
$limit = CliApplication::optionInt( $options, 'limit', 100 );
Non-integer input is rejected.
$ratio = CliApplication::optionFloat( $options, 'ratio' );
Non-numeric input is rejected.
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.
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.
For an operation that requires explicit confirmation, use:
CliApplication::confirm( 'Delete the example item?', $options );
The helper behaves consistently with the global options:
–yes accepts the confirmation;–yes, interactive execution asks [y/N];–no-interaction without –yes fails rather than silently proceeding.Do not implement a second confirmation parser inside the module callback.
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.
Do not build custom format handling in every command.
Use the CLI output helpers so global output behavior and machine-readable formats stay consistent.
CliApplication::writeValue( $data, $options, 'record' );
For JSON, writeValue() emits structured JSON.
For an associative array in record mode it produces a field/value representation.
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.
For a mutating command that has no result data beyond confirmation:
CliApplication::writeSuccess( 'Item updated.', $options );
This:
–quiet is active;–format=json is requested.
Do not replace it with echo.
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.
When a command generates a file with a natural filename, use:
$target = CliApplication::resolveOutputPath( $options, $filename );
This supports:
–output: use the current working directory;–output pointing to a directory: append the natural filename;–output pointing 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.
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.
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 → exit 2;Admidio\Infrastructure\Exception → exit 5;RuntimeException → exit 6;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.
Every module task registered through:
CliTaskRegistry::register(...)
specifies an existing Admidio component and requires an acting user.
Before the callback is run, the CLI:
–as;Component::isAdministrable() for ACCESS_ADMINISTRABLE;Component::isVisible() for ACCESS_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:
ACCESS_VISIBLE must 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.
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.
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.
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.
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:
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.
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.
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:
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.
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.
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:
Before adding a new command, verify that:
s singular form);ACCESS_ADMINISTRABLE unless the reused domain service implements the complete normal rights model;ACCESS_VISIBLE command retains record-level visibility checks;values;queryPrepared() patterns;CliApplication::confirm();CliApplication helpers;CliApplication::EXIT_SUCCESS;help COMMAND is complete;help –all –format=dokuwiki produces usable documentation;completion sees the expected command/options;cli:selfcheck passes;