WP-CLI: Extending with Custom Commands
WP-CLI is the maintenance / automation interface to a WordPress install. Almost no plugin ships CLI commands — even though wp myplugin import, wp myplugin clear-cache, wp myplugin run-sync are exactly what ops people want. The API is WP_CLI::add_command() and a class with PHPDoc-annotated methods.
When to use this skill
Trigger when ANY of the following is true:
- A plugin needs a CLI surface — bulk import / export, data migration, queue dispatch, debug introspection, scheduled-job force-run, cache clear, license activate.
- Code references
WP_CLI::add_command,WP_CLI::log,WP_CLI::success,WP_CLI::warning,WP_CLI::error,WP_CLI::confirm,WP_CLI::runcommand,WP_CLI::add_hook,WP_CLI\Utils\format_items,WP_CLI\Utils\make_progress_bar,WP_CLI\Utils\get_flag_value. - The user has a long-running admin task (
update_optionloop over 50k rows, AJAX-timing-out import) and wants to run it from terminal. - Code is checking
if ( defined( 'WP_CLI' ) && WP_CLI )and the body is empty / wrong.
The bootstrap — guard, then register
// In the plugin's main file or a dedicated CLI bootstrap.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
WP_CLI::add_command( 'myplugin', MyPlugin\CLI\Commands::class );
}
The class is instantiated lazily when WP-CLI dispatches a myplugin ... invocation. PHPDoc on each method is what drives synopsis / help. Public methods become subcommands. Method names are used as-is, so import_licenses registers wp myplugin import_licenses unless you add @subcommand import-licenses. Use @subcommand for normal hyphenated command names.
Full worked example (import + list with progress bar, dry-run, format args) lives in reference.md. The skeleton:
namespace MyPlugin\CLI;
final class Commands {
/**
* One-line description.
*
* ## OPTIONS
*
* <file>
* : Positional, required.
*
* [--dry-run]
* : Optional flag.
*
* @subcommand import-licenses
* @when after_wp_load
*/
public function import_licenses( array $args, array $assoc_args ): void {
[ $file ] = $args;
$dry = (bool) \WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false );
// ... work, with \WP_CLI::log() / success() / error() ...
}
}
PHPDoc drives the synopsis — get the format right
WP-CLI parses the docblock for command help / arg validation. The strict format:
| Line | Means |
|---|---|
| First paragraph | One-line short description (shown in wp help myplugin) |
## OPTIONS |
Begins the args section |
<name> |
Positional arg, required |
[<name>] |
Positional arg, optional |
[<name>...] |
Variadic positional |
--flag |
Boolean flag |
[--flag] |
Optional boolean flag |
[--key=<value>] |
Associative arg |
[--key=<value>...] |
Repeatable associative arg |
: description |
The next line is the description for the arg above |
## EXAMPLES |
Begins examples block |
@when before_wp_load / after_wp_load |
Controls when WP boots relative to the command (default: after_wp_load) |
@subcommand name-with-hyphen |
Exposes a PHP method under a CLI-safe command name |
@alias |
Alternate name for the command |
The arg pattern lines are NOT freeform — they MUST match WP-CLI's parser. Run wp help myplugin import-licenses to see what the parser made of your docblock; if the command is missing, check the method name / @subcommand tag first. If the ## OPTIONS section looks wrong there, the docblock is malformed.
I/O helpers — pick the right severity
Verified at WP_CLI class methods (reflection-confirmed):
\WP_CLI::line( $msg ); // plain stdout; no log prefix
\WP_CLI::log( $msg ); // also plain stdout but suppressed by --quiet
\WP_CLI::success( $msg ); // green "Success: ..."
\WP_CLI::warning( $msg ); // yellow "Warning: ..."
\WP_CLI::error( $msg, true ); // red "Error: ..." AND exit non-zero (default)
\WP_CLI::error( $msg, false ); // print error but continue
\WP_CLI::debug( $msg ); // only printed with --debug flag
\WP_CLI::confirm( $question, $assoc_args ); // y/n prompt; auto-yes when $assoc_args contains --yes
\WP_CLI::halt( $code ); // exit with custom code
error exits by default — use it for "abort the command". warning does not exit.
Formatted output — match the --format convention
Every WP-CLI command that lists data accepts --format=<table|csv|json|yaml|count>. Build your commands the same way. WP_CLI\Utils\format_items( $format, $items, $fields ) does the work (signature verified via reflection).
$format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' );
\WP_CLI\Utils\format_items( $format, $items, array( 'id', 'name', 'expires_at' ) );
Declare the format enum in the PHPDoc with the --- YAML-block syntax so WP-CLI validates user input — full example in reference.md:
* [--format=<format>]
* : Output format.
* ---
* default: table
* options:
* - table
* - csv
* - json
* - yaml
* - count
* ---
Invoking other commands — runcommand
Reflection-confirmed signature: WP_CLI::runcommand( $command, $options = [] ). Options:
| Option | Meaning |
|---|---|
launch |
true (default for after_wp_load boundary crossing): run in subprocess. false: run in current process (faster but shares state) |
return |
true: return the output as a string. 'stdout' / 'stderr' / 'return_code': return one piece. false (default): echo |
parse |
'json': JSON-decode the captured output |
exit_error |
false: don't exit on non-zero return from the inner command |
// Run another command and capture its JSON output.
$users = \WP_CLI::runcommand( 'user list --role=customer --format=json', array(
'return' => true,
'parse' => 'json',
) );
// Fire-and-forget; let it print to terminal.
\WP_CLI::runcommand( 'cache flush' );
Lifecycle hooks — WP_CLI::add_hook
Useful for command lifecycle logic. An ordinary active plugin is loaded during
WordPress bootstrap, so it cannot register code early enough to run before
WordPress loads; before_wp_load is for commands registered by WP-CLI packages
or an earlier bootstrap file.
| Hook | When |
|---|---|
before_wp_load |
Before any WP file is loaded |
after_wp_load |
After WP bootstrap, before commands run |
before_invoke:<command> |
Right before <command>; parent command hooks also fire for subcommands |
after_invoke:<command> |
Right after <command> |
before_run_command |
Before every command, after dispatch |
after_run_command |
After every command |
WP_CLI::add_hook( 'before_invoke:myplugin import-licenses', static function () {
\WP_CLI::log( 'Disabling Action Scheduler runners for the import...' );
remove_action( 'action_scheduler_run_queue', 'ActionScheduler::run_queue' );
} );
Long-running commands and progress bars
WP_CLI\Utils\make_progress_bar( $message, $count, $interval = 100 ) returns a progress bar (\cli\progress\Bar from the wp-cli/php-cli-tools lib). Call ->tick() (with optional increment) per item, ->finish() at the end.
$bar = \WP_CLI\Utils\make_progress_bar( 'Migrating', $total );
foreach ( $rows as $row ) {
migrate_one( $row );
$bar->tick();
}
$bar->finish();
For commands that take >30 seconds, flush WP object cache between batches and wp_get_db_schema()-style commands — the persistent $wpdb accumulates query log + cached results. Common pattern:
foreach ( array_chunk( $ids, 500 ) as $chunk ) {
process_chunk( $chunk );
\WP_CLI\Utils\wp_clear_object_cache(); // utility function, reflection-confirmed
}
Critical rules
- Always guard registration with
defined( 'WP_CLI' ) && WP_CLI. Otherwise theWP_CLI::add_commandcall fatals on every web request — the class doesn't exist outside CLI runs. - Method visibility matters. Only
publicmethods are exposed as subcommands.protected/privatehelpers don't leak — use them freely. - Underscores are not automatically converted to hyphens. A method named
import_licensesregisters asimport_licenses; add@subcommand import-licenseswhen the CLI command should be hyphenated. - Register a class name, not an already-instantiated object, when you want lazy loading. With a class string, WP-CLI reflects PHPDoc during registration/help and only constructs the class when the command is invoked. Constructors should still stay cheap because every real command run pays for them.
@when after_wp_loadis the default and the normal plugin mode. A command declared by an active plugin is registered only while WP is loading, too late for a realbefore_wp_loadcommand. Put WP-independent early commands in a WP-CLI package/bootstrap loaded before core.WP_CLI::error()exits with non-zero by default. Passfalseas the 2nd arg only when you genuinely want to print "Error:" but continue.- Format your output via
WP_CLI\Utils\format_items(), not byecho. Users expect--format=jsonto work; rolling your own table breaks pipelines and scripts. - Don't bypass
WP_CLI::logwith rawecho.echodoesn't respect--quiet; logs always do. - Pass
$assoc_argstoWP_CLI::confirm()when you want--yessupport. Callingconfirm( $question )prompts even if the user supplied--yes. - Don't query the database from
before_wp_load.$wpdbis not initialized yet. Useafter_wp_load(the default) for anything touching WP state. - Don't
wp_die()inside a CLI command — it bypasses WP-CLI's error formatting and produces ugly stack traces. UseWP_CLI::error().
Common AI mistakes
See reference.md for before/after snippets covering: missing defined('WP_CLI') guard, hand-rolled synopsis logic instead of PHPDoc, raw echo instead of WP_CLI::log/success/error, hand-built tables that break --format=json, and wp_die() inside a CLI command.
Cross-references
- See
wp-plugin-cronwhen the CLI command's job is to dispatch background work — Action Scheduler / cron is the right destination, not "do it all in the CLI invocation". - See
wp-rest-apiwhen the same logic also needs a REST surface — extract the core into a service class, expose via both CLI command and REST route. - See
wp-locale-and-datesforwp_date()output in command tables — locale-aware display matters in--format=table.
What this skill does NOT cover
- Distributing the command as a standalone WP-CLI package (
composer.json,wp package install). Plugin-shipped commands cover 95% of plugin needs. - Writing your own custom output formatter (subclassing
WP_CLI\Formatter). Almost never needed —format_itemscovers everything. - The package authoring conventions (
wp-cli/dotenv-commandstyle). Out of scope.
References
WP_CLI::add_command()— reflection-verified signatureadd_command( $name, $callable, $args = [] ).WP_CLI\Utils\format_items()— reflection-verifiedformat_items( $format, $items, $fields ).WP_CLI\Utils\make_progress_bar()— reflection-verifiedmake_progress_bar( $message, $count, $interval = 100 ).WP_CLI\Utils\get_flag_value()—get_flag_value( $assoc_args, $flag, $default = null ).- WP-CLI handbook (canonical docs): https://make.wordpress.org/cli/handbook/references/internal-api/
- WP-CLI hook system: https://make.wordpress.org/cli/handbook/guides/hook-system/
- Official documentation: https://make.wordpress.org/cli/handbook/references/internal-api/wp-cli-add-command/
- Official documentation: https://make.wordpress.org/cli/handbook/guides/commands-cookbook/