Claude Skill

wp-cli-extending

Add custom WP-CLI commands to a WordPress plugin via `WP_CLI::add_command( $name, $callable, $args )`. Covers the class-based command pattern with PHPDoc-driven synopsis, positional vs `--flag` args, I/O helpers (`success` / `log` / `warning` / `error` / `confirm` / `debug`), for

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-wordpress_wp-cli-extending-52f6020.zip · 6 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/wordpress/wp-cli-extending
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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_option loop 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 the WP_CLI::add_command call fatals on every web request — the class doesn't exist outside CLI runs.
  • Method visibility matters. Only public methods are exposed as subcommands. protected / private helpers don't leak — use them freely.
  • Underscores are not automatically converted to hyphens. A method named import_licenses registers as import_licenses; add @subcommand import-licenses when 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_load is 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 real before_wp_load command. Put WP-independent early commands in a WP-CLI package/bootstrap loaded before core.
  • WP_CLI::error() exits with non-zero by default. Pass false as the 2nd arg only when you genuinely want to print "Error:" but continue.
  • Format your output via WP_CLI\Utils\format_items(), not by echo. Users expect --format=json to work; rolling your own table breaks pipelines and scripts.
  • Don't bypass WP_CLI::log with raw echo. echo doesn't respect --quiet; logs always do.
  • Pass $assoc_args to WP_CLI::confirm() when you want --yes support. Calling confirm( $question ) prompts even if the user supplied --yes.
  • Don't query the database from before_wp_load. $wpdb is not initialized yet. Use after_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. Use WP_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-cron when 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-api when 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-dates for wp_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_items covers everything.
  • The package authoring conventions (wp-cli/dotenv-command style). Out of scope.

References

Files (wp-agent-skills)
  • reference.md 4.3 KB
    # wp-cli-extending — Reference: full examples
    
    Long-form examples kept out of `SKILL.md` for brevity. The main skill is the
    contract + critical rules; this file is the worked code.
    
    ## Full command class — import + list with all the right idioms
    
    ```php
    namespace MyPlugin\CLI;
    
    final class Commands {
    
        /**
         * Imports licenses from a CSV file.
         *
         * ## OPTIONS
         *
         * <file>
         * : Path to CSV file.
         *
         * [--dry-run]
         * : Parse and report counts without writing.
         *
         * [--batch-size=<n>]
         * : Rows per chunk. Default: 100.
         *
         * ## EXAMPLES
         *
         *     wp myplugin import-licenses ./batch.csv --batch-size=500
         *     wp myplugin import-licenses ./batch.csv --dry-run
         *
         * @subcommand import-licenses
         * @when after_wp_load
         */
        public function import_licenses( array $args, array $assoc_args ): void {
            [ $file ] = $args;
            $dry_run    = (bool) \WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false );
            $batch_size = (int)  \WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 100 );
    
            if ( ! is_readable( $file ) ) {
                \WP_CLI::error( "Cannot read file: $file" );
            }
    
            $rows  = \MyPlugin\License\Importer::parse( $file );
            $total = count( $rows );
            \WP_CLI::log( sprintf( 'Parsed %d rows.', $total ) );
    
            if ( $dry_run ) {
                \WP_CLI::success( 'Dry run complete. No changes written.' );
                return;
            }
    
            $progress = \WP_CLI\Utils\make_progress_bar( 'Importing', $total );
            foreach ( array_chunk( $rows, $batch_size ) as $chunk ) {
                \MyPlugin\License\Importer::write_chunk( $chunk );
                $progress->tick( count( $chunk ) );
            }
            $progress->finish();
    
            \WP_CLI::success( sprintf( 'Imported %d licenses.', $total ) );
        }
    
        /**
         * Lists active licenses.
         *
         * ## OPTIONS
         *
         * [--format=<format>]
         * : Output format.
         * ---
         * default: table
         * options:
         *   - table
         *   - csv
         *   - json
         *   - yaml
         *   - count
         * ---
         *
         * [--fields=<fields>]
         * : Limit output to specific fields (comma-separated).
         *
         * @subcommand list-licenses
         */
        public function list_licenses( array $args, array $assoc_args ): void {
            $items  = \MyPlugin\License\Repo::all_active();
            $fields = ! empty( $assoc_args['fields'] )
                ? array_map( 'trim', explode( ',', $assoc_args['fields'] ) )
                : array( 'id', 'key', 'product', 'expires_at' );
    
            $format = \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' );
    
            \WP_CLI\Utils\format_items( $format, $items, $fields );
        }
    }
    ```
    
    ## Common before/after AI mistakes
    
    ```php
    // WRONG — fatals on every web request (WP_CLI class doesn't exist there)
    WP_CLI::add_command( 'myplugin', MyClass::class );
    
    // RIGHT — guard
    if ( defined( 'WP_CLI' ) && WP_CLI ) {
        WP_CLI::add_command( 'myplugin', MyClass::class );
    }
    ```
    
    ```php
    // WRONG — invents synopsis in code; WP-CLI parser ignores it, help is wrong
    public function import( $args, $assoc_args ) {
        if ( empty( $assoc_args['file'] ) ) {
            echo "Usage: wp myplugin import --file=<path>\n";
            return;
        }
    }
    
    // RIGHT — declare in PHPDoc; WP-CLI shows usage and validates
    /**
     * ## OPTIONS
     *
     * --file=<path>
     * : The CSV file.
     */
    public function import( $args, $assoc_args ) { /* ... */ }
    ```
    
    ```php
    // WRONG — raw echo doesn't respect --quiet, gets no colour, no exit code
    echo "Done.\n";
    echo "ERROR: something went wrong\n";
    
    // RIGHT — use WP-CLI helpers
    \WP_CLI::success( 'Done.' );
    \WP_CLI::error( 'Something went wrong.' );   // also exits non-zero
    ```
    
    ```php
    // WRONG — hand-built table; --format=json now broken for scripts
    foreach ( $items as $row ) {
        printf( "%-10s %s\n", $row['id'], $row['name'] );
    }
    
    // RIGHT — let WP-CLI format it; users get table/csv/json/yaml for free
    \WP_CLI\Utils\format_items(
        \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' ),
        $items,
        array( 'id', 'name' )
    );
    ```
    
    ```php
    // WRONG — wp_die in a CLI command produces an HTML / fatal-looking output
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_die( 'Not allowed.' );
    }
    
    // RIGHT — WP-CLI error
    if ( ! current_user_can( 'manage_options' ) ) {
        \WP_CLI::error( 'Not allowed.' );
    }
    ```
    
  • SKILL.md 12.6 KB
    ---
    name: wp-cli-extending
    description: Add custom WP-CLI commands to a WordPress plugin via `WP_CLI::add_command( $name, $callable, $args )`. Covers the class-based command pattern with PHPDoc-driven synopsis, positional vs `--flag` args, I/O helpers (`success` / `log` / `warning` / `error` / `confirm` / `debug`), formatted output via `WP_CLI\Utils\format_items()` + `--format=table|csv|json|yaml|count`, progress bars with `WP_CLI\Utils\make_progress_bar()`, `WP_CLI::runcommand()` for invoking other commands, lifecycle hooks (`before_wp_load`, `before_invoke:{cmd}`, `after_invoke:{cmd}`), and the `defined( 'WP_CLI' ) && WP_CLI` registration guard. Use for plugin bulk import, data migration, queue dispatch, debug introspection, or any CLI surface.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-toolchain-tested: "WP-CLI 2.10 - 2.11"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # 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_option` loop 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
    
    ```php
    // 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:
    
    ```php
    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):
    
    ```php
    \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).
    
    ```php
    $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 |
    
    ```php
    // 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 |
    
    ```php
    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.
    
    ```php
    $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:
    
    ```php
    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 the `WP_CLI::add_command` call fatals on every web request — the class doesn't exist outside CLI runs.
    - **Method visibility matters**. Only `public` methods are exposed as subcommands. `protected` / `private` helpers don't leak — use them freely.
    - **Underscores are not automatically converted to hyphens**. A method named `import_licenses` registers as `import_licenses`; add `@subcommand import-licenses` when 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_load` is 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 real `before_wp_load` command. Put WP-independent early commands in a
      WP-CLI package/bootstrap loaded before core.
    - **`WP_CLI::error()` exits with non-zero by default**. Pass `false` as the 2nd arg only when you genuinely want to print "Error:" but continue.
    - **Format your output via `WP_CLI\Utils\format_items()`, not by `echo`**. Users expect `--format=json` to work; rolling your own table breaks pipelines and scripts.
    - **Don't bypass `WP_CLI::log` with raw `echo`**. `echo` doesn't respect `--quiet`; logs always do.
    - **Pass `$assoc_args` to `WP_CLI::confirm()` when you want `--yes` support**. Calling `confirm( $question )` prompts even if the user supplied `--yes`.
    - **Don't query the database from `before_wp_load`**. `$wpdb` is not initialized yet. Use `after_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. Use `WP_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-cron`** when 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-api`** when 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-dates`** for `wp_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_items` covers everything.
    - The package authoring conventions (`wp-cli/dotenv-command` style). Out of scope.
    
    ## References
    
    - `WP_CLI::add_command()` — reflection-verified signature `add_command( $name, $callable, $args = [] )`.
    - `WP_CLI\Utils\format_items()` — reflection-verified `format_items( $format, $items, $fields )`.
    - `WP_CLI\Utils\make_progress_bar()` — reflection-verified `make_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/>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related