BetterWPCLI - The missing parts to the already awesome WP-CLI
BetterWPCLI is a small, zero-dependencies, PHP library that helps you build enterprise WordPress command-line applications.
BetterWPCLI does not replace or take over any functionality of the wp
runner.
Instead, it sits between WP-CLI and your custom commands.
- Motivation
- Installation
- Usage
- Contributing
- Issues and PR's
- Security
- Credits
We developed this library for the WordPress related components of the Snicco project due to the following reasons:
-
❌ WP-CLI has no native support for dependency-injection and does not support lazy-loading of commands.
-
❌ WP-CLI encourages command configuration in meta language or by hard-coded string names.
- see Why Config Coding Sucks.
- "Was it
repeating
or was itmultiple
?", "Was itlong_description
orlong_desc
?"
-
❌ WP-CLI has inconsistent and unconfigurable handling of writing to
STDOUT
andSTDERR
.WP_CLI::log()
,WP_CLI::success()
,WP_CLI::line()
write toSTDOUT
WP_CLI::warning()
andWP_CLI::error()
write toSTDERR
.- Progress bars are written to
STDOUT
making command piping impossible. - Prompts for input are written to
STDOUT
making command piping impossible. - Uncaught PHP notices (or other errors) are written to
STDOUT
making command piping impossible.
-
❌ WP-CLI has no error handling. Thrown exceptions go directly to the global shutdown handler (
wp_die
) and show up in the terminal as the dreaded"There has been a critical error on this website.Learn more about troubleshooting WordPress."
. Thus, they also go toSTDOUT
instead ofSTDER
. -
❌ WP-CLI can detect ANSI support only for
STDOUT
and not individually for bothSTDOUT
andSTDERR
.- If you are redirecting
STDOUT
you probably don't wantSTDERR
to lose all colorization.
- If you are redirecting
-
❌ WP-CLI commands are hard to test because its encouraged to use the static
WP_CLI
class directly in your command instead of using someInput/Output
abstraction. -
❌ WP-CLI does not play nicely with static analysers like psalm and phpstan.
- You receive two completely untyped arrays in your command classes.
- You have no easy way of separating positional arguments from repeating positional arguments.
BetterWPCLI aims to solve all of these problems while providing you many additional features.
BetterWPCLI is specifically designed to be usable in distributed code like public plugins.
BetterWPCLI is distributed via composer.
composer require snicco/better-wp-cli
All commands extend the Command class.
One Command class is responsible for handling exactly ONE command and defining its own synopsis.
The command class reproduces the example command described in the WP-CLI commands cookbook .
use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Synopsis\Synopsis;
use Snicco\Component\BetterWPCLI\Synopsis\InputFlag;
use Snicco\Component\BetterWPCLI\Synopsis\InputOption;
use Snicco\Component\BetterWPCLI\Synopsis\InputArgument;
class ExampleCommand extends Command {
// You can set an explicit command name.
// If a command name is not set explicitly, it's determined from the class name.
protected static string $name = 'example';
// The short description of the command that will be shown
// when running "wp help example"
protected static string $short_description = 'Prints a greeting'
// If a long description is not set explicitly it will default to
// the short_description property.
protected static string $long_description = '## EXAMPLES' . "\n\n" . 'wp example hello Newman'
public function execute(Input $input, Output $output) : int {
$name = $input->getArgument('name'); // (string) Always a string
$type = $input->getOption('flag'); // (string) Always a string
$honk = $input->getFlag('honk'); // (bool) Always a boolean
// outputs a message followed by a "\n"
$output->writeln("$type: Hello $name!");
// Writes directly to the output stream without newlines
//$output->write('You are about');
//$output->write(' to honk');
// Writes to "\n" chars
//$output->newLine(2);
if($honk) {
$output->writeln("Honk");
}
// (This is equivalent to returning int(0))
return Command::SUCCESS;
// (This is equivalent to returning int(1))
// return Command::FAILURE;
// (This is equivalent to returning int(2))
// return Command::INVALID
}
public static function synopsis() : Synopsis{
return new Synopsis(
new InputArgument(
'name',
'The name of the person to great',
InputArgument::REQUIRED
),
// You can combine options by using bit flags.
// new InputArgument(
// 'some-other-arg',
// 'This is another arg',
// InputArgument::REQUIRED | InputArgument::REPEATING
//),
new InputOption(
'type',
'Whether or not to greet the person with success or error.',
InputOption::OPTIONAL,
'success',
['success', 'error']
),
new InputFlag('honk')
);
}
}
The Synopsis
value object helps you to create the
command synopsis using a clear PHP API.
The Synopsis
has a rich set of validation rules
that are only implicit in the WP-CLI. This helps you prevent certain gotchas right away like:
- Having duplicate names for arguments/options/flags.
- Registering a positional argument after a repeating argument.
- Setting a default value that is not in the list of allowed values.
- ...
A Synopsis
consists of zero or more positional arguments, options or flags.
These a represented by their respective classes:
The Command
class has several inbuilt flags that you can use in your commands.
You can automatically add them to all your commands by adding them to the parent synopsis.
This is totally optional.
use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Synopsis\Synopsis;
use Snicco\Component\BetterWPCLI\Synopsis\InputArgument;
class MyCommand extends Command {
public static function synopsis() : Synopsis{
return parent::synopsis()->with([
new InputArgument(
'name',
'The name of the person to great',
InputArgument::REQUIRED
),
]);
}
}
This will add the following command synopsis:
Commands are registered by using the WPCLIApplication
class.
if(!defined('WP_CLI')) {
return;
}
use Snicco\Component\BetterWPCLI\WPCLIApplication;
use Snicco\Component\BetterWPCLI\CommandLoader\ArrayCommandLoader;
// The namespace will be prepended to all your commands automatically.
$command_namespace = 'snicco';
// The command loader is responsible for lazily loading your commands.
// The second argument is a callable that should return an instance of
// a command by its name. This should typically be a call to your dependency injection container.
// This array can come from a configuration file.
$command_classes = [
ExampleCommand::class,
FooCommand::class,
BarCommand::class,
];
$container = /* Your dependency injection container or another factory class */
$factory = function (string $command_class) use ($container) {
return $container->get($command_class);
}
$command_loader = new ArrayCommandLoader($command_classes, $factory);
$application = new WPCLIApplication($command_namespace);
$application->registerCommands();
Console input is abstracted away through an Input
interface.
All commands will receive an instance of Input
that holds all the passed arguments.
use Snicco\Component\BetterWPCLI\Input\Input
use Snicco\Component\BetterWPCLI\Output\Output
use Snicco\Component\BetterWPCLI\Synopsis\Synopsis;
use Snicco\Component\BetterWPCLI\Synopsis\InputFlag;
use Snicco\Component\BetterWPCLI\Synopsis\InputOption;
use Snicco\Component\BetterWPCLI\Synopsis\InputArgument;
// ...
public static function synopsis(): Synopsis
{
return new Synopsis(
new InputArgument(
'role',
'The role that should be assigned to the users',
),
new InputArgument(
'ids',
'A list of user ids that should be assigned to passed role',
InputArgument::REQUIRED | InputArgument::REPEATING
),
new InputFlag(
'notify',
'Send the user an email about his new role'
),
new InputOption(
'some-option',
),
);
}
// ...
public function execute(Input $input, Output $output): int
{
$output->writeln([
'Changing user roles',
'===================',
]);
// Arguments are retrieved by their name.
$role = $input->getArgument('role'); // (string)
// The second argument is returned if the option/argument was not passed.
$option = $input->getOption('some-option', 'some-default-value'); // (string)
$users = $input->getRepeatingArgument('ids'); // (string[]) and array of ids.
$notify = $input->getFlag('notify', false);
foreach($users as $id) {
// assign role here
if($notify) {
// send email here
}
}
return Command::SUCCESS;
}
Console output is abstracted away through an Output
interface.
All commands will receive an instance of Output
.
Its recommend that you write use this class in your commands to write to the output stream.
This way your commands will stay testable as you can just substitute this Output
interface
with a test double.
However, there is nothing preventing you from using the WP_CLI
class in your commands.
use Snicco\Component\BetterWPCLI\Input\Input
use Snicco\Component\BetterWPCLI\Output\Output
// ...
protected function execute(Input $input, Output $output): int
{
// outputs multiple lines to the console (adding "\n" at the end of each line)
$output->writeln([
'Starting the command',
'============',
'',
]);
// outputs a message followed by a "\n"
$output->writeln('Doing something!');
// outputs a message without adding a "\n" at the end of the line
$output->write('You are about to ');
$output->write('do something here');
// Outputs 3 "\n" chars.
$output->newLine(3);
// You can also use the WP_CLI class.
// WP_CLI::debug('doing something');
return Command::SUCCESS;
}
BetterWPCLI has a concept of verbosity levels to allow the user to choose how detailed the command output should be.
See: default flags for instructions of adding the flags to your commands.
WP-CLI has a similar concept but only allows you to choose between quiet
(no output)
and debug
(extremely verbose output including wp-cli internals.)
BetterWPCLI has the following five verbosity levels which can be either set per command or by using a
SHELL_VERBOSITY
environment value.
(Command line arguments have a higher priority then SHELL_VERBOSITY
and --debug
and --quiet
overwrite all values
unique to BetterWPCLI.)
CLI option | SHELL_VERBOSITY | PHP constant |
---|---|---|
--quiet (wp-cli flag) |
-1 |
Verbosity::QUIET |
(none) | 0 | Verbosity::NORMAL |
--v |
1 |
Verbosity::VERBOSE |
--vv |
2 |
Verbosity::VERY_VERBOSE |
--vvv or --debug (wp-cli flag) |
3 |
Verbosity::DEBUG |
It is possible to print specific information only for specific verbosity levels.
use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Verbosity;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
class AssignUserRoles extends Command {
public function execute(Input $input,Output $output) : int{
$output->writeln('Always printed', Verbosity::QUIET);
$output->writeln('only printed for verbosity normal and above', Verbosity::NORMAL);
$output->writeln('only printed for verbosity verbose and above', Verbosity::VERBOSE);
$output->writeln('only printed for verbosity very-verbose and above', Verbosity::VERY_VERBOSE);
$output->writeln('only printed for verbosity debug', Verbosity::DEBUG);
return Command::SUCCESS;
}
// .. synopsis defined here.
}
BetterWPCLI provides you a utility class SniccoStyle
that you can instantiate in your
commands.
This class contains many helpers methods for creating rich console output.
The style is based on the styling of the symfony/console
package.
Color support is automatically detected based on the operating system, whether the command is piped and the
provided flags like: --no-color
, --no-ansi
.
See: default flags.
This class will write to STDERR
unless you configure it not too.
You should use the Output
instance to write important information to STDOUT
.
Important information is information that could in theory be piped into other commands.
If your command does not output such information just return Command::SUCCESS
and don't output anything.
Silence is Golden.
use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
// ...
protected function execute(Input $input, Output $output): int
{
$io = new SniccoStyle($input, $output);
// ...
// Not so important information
//$io->title('Command title);
$output->writeln('Some important command output that should be piped.');
return Command::SUCCESS;
}
The title()
method should be used once at the start of a command.
$io->title('This is the command title');
The section()
method can be used to separate multiple coherent sections of a command
$io->section('This is a new section');
The info()
method can be used signalize successful completion of a section.
$io->info('This is an info');
The note()
method can be used to draw extra attention to the message. Use this sparingly.
$io->note('This is a note');
The text()
method output regular text without colorization.
// Passing an array is optional.
$io->text(['This is a text', 'This is another text']);
The success()
method should be used once at the end of a command.
// Passing an array is optional.
$io->success(['This command', 'was successful']);
The warning()
method should be used once at the end of a command.
// Passing an array is optional.
$io->warning(['This command', 'displays a warning']);
The error()
method should be used once at the end of a command if it failed.
// Passing an array is optional.
$io->error(['This command', 'did not work']);
The SniccoStyle
class provides several methods to
get more information from the user.
If the command was run with the --no-interaction
flag the default answer will be used automatically.
See: default flags.
All output produced by interactive questions is written to STDERR
.
use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
// ...
protected function execute(Input $input, Output $output): int
{
$io = new SniccoStyle($input, $output);
// The second argument is the default value
if(!$io->confirm('Are you sure that you want to continue', false)) {
$io->warning('Command aborted');
return Command::SUCCESS;
}
// Proceed
return Command::SUCCESS;
}
use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
// ...
protected function execute(Input $input, Output $output): int
{
$io = new SniccoStyle($input, $output);
$domain = $io->ask('Please tell use your company domain', 'snicco.io');
$output->writeln('Your domain is: '. $domain);
}
Hidden input
You can also ask a question and hide the response.
This will be done by changing the stty
mode of the terminal.
If stty
is not available, it will fall back to visible input unless you configure it otherwise.
use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Question\Question;
// ...
protected function execute(Input $input, Output $output): int
{
$io = new SniccoStyle($input, $output);
// This will fall back to visible input if stty is not available.
// e.g. on Windows
$secret = $io->askHidden('What is your secret?')
$question = (new Question('What is your secret'))
->withHiddenInput()
->withFallbackVisibleInput(false);
// This will throw an exception if hidden input can not be ensured.
$secret = $io->askQuestion($question);
//
}
You can validate the provided answer of the user. If the validation fails the user will be presented with the same question again.
You can also set a maximum amount of attempts. If the maximum attempts are exceeded an InvalidAnswer exception will be thrown.
use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Output\Output;
use Snicco\Component\BetterWPCLI\Question\Question;
use Snicco\Component\BetterWPCLI\Exception\InvalidAnswer;
// ...
protected function execute(Input $input, Output $output): int
{
$io = new SniccoStyle($input, $output);
$validator = function (string $answer) :void {
if(strlen($answer) < 5) {
throw new InvalidAnswer('The name must have at least 6 characters.');
}
};
$attempts = 2;
$question = new Question('Please enter a name', 'default_name', $validator, $attempts);
$answer = $io->askQuestion($question);
}
BetterWPCLI comes with very solid exception/error-handling.
This behaviour is however totally isolated and only applies to YOUR commands. Core commands or commands by other plugins are not affected in any way.
If your command throws an uncaught exception two things will happen:
- The exception is displayed in
STDERR
while taking the current verbosity into consideration. - The exception is logged using the
Logger
interface. (This is the third argument passed into theWPCLIApplication
)
This is how exceptions are displayed with different verbosity levels:
VERBOSTIY::NORMAL
:
VERBOSTIY::VERBOSE
:
VERBOSTIY::VERY_VERBOSE
and above:
You can disable catching exceptions although this is not recommended.
use Snicco\Component\BetterWPCLI\WPCLIApplication;;
$command_loader = new ArrayCommandLoader($command_classes, $factory);
$application = new WPCLIApplication($command_namespace);
// This disables exception handling.
//All exceptions are now handled globally by WordPress again.
$application->catchException(false);
$application->registerCommands();
By default a StdErrLogger
is used to log exceptions using error_log
.
This class is suitable for usage in distributed code as it will log exceptions to the location
configured in WP_DEBUG_LOG
. If you want to use a custom logger you have to pass it as the third argument
when creating your WPCLIApplication
.
The Logger
will create a log record for all uncaught exceptions during your command lifecycle + all commands that return a non-zero exit code.
In a normal WP-CLI
command errors such as notices, warnings and deprecations
are not handled at all. Instead, they bubble up to the global PHP error handler.
It is a best practice to treat notices and warnings as exceptions.
BetterWPCLI will promote all errors during YOUR command to instances of ErrorException
.
The following code:
use Snicco\Component\BetterWPCLI\Command;
use Snicco\Component\BetterWPCLI\Style\SniccoStyle;
use Snicco\Component\BetterWPCLI\Input\Input;
use Snicco\Component\BetterWPCLI\Output\Output;
// ...
protected function execute(Input $input, Output $output): int
{
$arr = ['foo'];
$foo = $arr[1];
//
return Command::SUCCESS;
}
will throw an exception and exit with code 1
.
By default, all error including deprecations are promoted to exceptions.
If you find this to strict for your production environment you can customize the behaviour.
use Snicco\Component\BetterWPCLI\WPCLIApplication;;
$command_loader = new ArrayCommandLoader($command_classes, $factory);
$application = new WPCLIApplication($command_namespace);
// This is the default setting
$application->throwExceptionsAt(E_ALL);
// Throw exceptions for all errors expect deprecations.
$application->throwExceptionsAt(E_ALL - E_DEPRECATED - E_USER_DEPRECATED);
// This disables the behaviour entirely (NOT RECOMMENDED)
$application->throwExceptionsAt(0);
$application->registerCommands();
This package comes with dedicated testing utilities which are in a separate package snicco/better-wp-cli-testing
.
use Snicco\Component\BetterWPCLI\Testing\CommandTester;
$tester = new CommandTester(new CreateUserCommand());
$tester->run(['calvin', '[email protected]'], ['send-email' => true]);
$tester->assertCommandIsSuccessful();
$tester->assertStatusCode(0);
$tester->seeInStdout('User created!');
$tester->dontSeeInStderr('Fail');
This repository is a read-only split of the development repo of the Snicco project.
This is how you can contribute.
Please report issues in the Snicco monorepo.
If you discover a security vulnerability within BetterWPAPI, please follow our disclosure procedure.
Inspecting the source of the symfony console symfony/console
was invaluable to developing this library.