WP-CLI

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

WP-CLI is the command-line interface for WordPress: wp plugin update --all, wp search-replace, wp db export and hundreds of other commands do from a shell what the dashboard does with clicks, and more. Administrators use it to install, update, back up and migrate sites; developers use it to inspect and seed data and to give plugins commands of their own. This page covers installing and configuring it, remote sites, the commands other pages already use (linked, not repeated), packages, and the custom wp lumen import command of the lumen-catalog plugin.

The page was written against WP-CLI 2.12.0 (released 2025-05-07; wp cli check-update still answered "WP-CLI is at the latest version"), WordPress 7.1.3 (2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, and WPCS 3.4.1, and checked on 2026-10-28. WordPress 7.2 is in beta, planned for 8 December 2026. WP-CLI’s main branch is 3.0 in development: it raises the PHP requirement from 5.6 (2.12) to 7.2.24, and the online handbook already describes some 3.x features, flagged below where they matter. Every output comes from containers built from wordpress:cli-2.12.0-php8.4, wordpress:7.1.3-php8.4-apache and mariadb:11.8, a local site at https://lumenpress.test and a staging site reached over SSH.

How WP-CLI runs WordPress

wp is a PHP Phar that boots WordPress like a web request (wp-config.php, then wp-settings.php) but without HTTP: no $_SERVER['HTTP_HOST'] (--url supplies one) and no logged-in user (--user sets one). Plugins see the constant WP_CLI defined as true. Consequences:

  • It runs as the shell user. Files it writes (plugins, uploads, .htaccess) belong to whoever ran wp, not to the web server user; see File Permissions and Filesystem.

  • Capabilities are not checked by WP-CLI itself. Anyone with shell access to the files has full control of the site, so --user decides authorship and what current_user_can() returns inside plugin code, not whether a command is allowed. Running wp as root is refused unless --allow-root is passed.

  • Context. --context (2.6+) loads WordPress as cli, admin (for code behind is_admin()) or auto, the default since 2.7: admin for wp plugin and wp theme commands, cli for the rest.

  • Commands are PHP classes in separate packages (wp-cli/core-command and others, shipped as wp-cli/wp-cli-bundle); plugins and packages add more with the same API.

Installing

The Phar

The recommended installation is one file on the PATH. Pinning the release and checking its SHA-512 sum makes the install reproducible; the handbook also documents GPG signatures.

curl -LO https://github.com/wp-cli/wp-cli/releases/download/v2.12.0/wp-cli-2.12.0.phar
curl -LO https://github.com/wp-cli/wp-cli/releases/download/v2.12.0/wp-cli-2.12.0.phar.sha512
echo "$(cat wp-cli-2.12.0.phar.sha512)  wp-cli-2.12.0.phar" | sha512sum -c -
php wp-cli-2.12.0.phar --info
chmod +x wp-cli-2.12.0.phar
sudo mv wp-cli-2.12.0.phar /usr/local/bin/wp
wp --version

In a php:8.4-cli container this printed wp-cli-2.12.0.phar: OK and WP-CLI 2.12.0; wp --info shows the PHP binary, config files and package folder in use. The handbook’s unversioned URL (.../gh-pages/phar/wp-cli.phar) always serves the newest build, fine on a workstation but not in provisioning. wp cli update updates a Phar install; Composer (wp-cli/wp-cli-bundle), Homebrew and Debian packages are updated by their own tools.

The Docker image

The wordpress Docker Official Image has cli variants (wordpress:cli-2.12.0-php8.4: Alpine, PHP, the Phar). The cli service of Local Development Environments shares the WordPress volume and settings, so commands run as one-off containers; paths are container paths, so a host file is mounted first:

docker compose run --rm cli wp plugin list
docker compose run --rm -v "$PWD/data:/data:ro" cli wp lumen import /data/books.csv --user=lena.catalogue

The image runs as Alpine’s www-data (UID 82) while the Debian apache variants use UID 33, so the image documentation recommends --user 33:33 and HOME=/tmp against a Debian-based install, as the Compose service does. With wp-env the equivalent is wp-env run cli wp ....

Configuration files

Every global parameter, and the default arguments of any subcommand, can live in YAML. WP-CLI reads, from highest to lowest priority: the command line, a project file found in the current directory or the nearest parent (wp-cli.local.yml, else wp-cli.yml), the global ~/.wp-cli/config.yml (WP_CLI_CONFIG_PATH moves it), and its defaults. The upward search stops when it would leave one WordPress install for an enclosing one.

The Lumen Press repository keeps WordPress in a wordpress/ folder and commits this file at its root:

wp-cli.yml
# Lumen Press: shared WP-CLI configuration, committed to the repository.
path: wordpress
url: https://lumenpress.test
apache_modules:
  - mod_rewrite
disabled_commands:
  - db drop
  - db reset

# Defaults for one subcommand.
post list:
  fields: ID,post_title,post_status

@staging:
  ssh: deploy@staging.lumenpress.example/srv/www/staging.lumenpress.example
  url: https://staging.lumenpress.example
@production:
  ssh: deploy@lumenpress.example:2222/srv/www/lumenpress.example
  url: https://lumenpress.example
@remote:
  - @staging
  - @production

Each developer may add a git-ignored wp-cli.local.yml. In 2.12 the two files are not merged: Runner::get_project_config_path() returns the first of wp-cli.local.yml and wp-cli.yml that exists, so a local file alone drops the shared one (wp cli info listed only the local file, and every command failed with "This does not seem to be a WordPress installation" because path was gone). The _ key fixes it:

wp-cli.local.yml
# Per-developer overrides, ignored by Git. Without "inherit", wp-cli.yml is not read at all.
_:
  inherit: wp-cli.yml
user: maria.editor

From the repository root wp option get home printed https://lumenpress.test, the current user was maria.editor (or admin with --user=admin), wp post list showed only the three configured fields, and wp db drop --yes stopped with "The 'db drop' command has been disabled from the config file."

Key Effect

path, url, user, ssh, http, color, debug, quiet, context, skip-plugins, skip-themes, skip-packages

Defaults for the global parameters below; path is relative to the file

require

PHP files loaded before every command (a list); the place for personal helper commands

apache_modules

Apache modules WP-CLI reports as loaded. With mod_rewrite listed, wp rewrite flush --hard writes .htaccess; without it, it warns "Regenerating a .htaccess file requires special configuration" and writes nothing (both checked)

disabled_commands

Commands refused with an error; a guard against accidents, not a security boundary (the same user can edit the file)

<command> <subcommand>:, @alias:

Default arguments for one subcommand; a named site or a group of aliases

_ with inherit and merge

Read another YAML file first, and merge subcommand defaults with the parent file instead of replacing them

A shared file should not set user: admin, which silently runs every command as an administrator; commands that need one (global styles changes) pass --user=admin.

Global parameters

Global parameters work with every command, before or after it. 2.12 has no short forms: wp config create -q failed with "Too many positional arguments: -q", where --quiet works.

Parameter Use

--path=<path>

WordPress root, when not running from it

--url=<url>

The site URL; selects the site on Multisite and sets HTTP_HOST for code that reads it

--user=<id-login-or-email>

Current user, for authorship and capability-dependent code

--ssh=[<scheme>:][<user>@]<host>[:<port>][<path>]

Run the command on another machine or container (next section)

--skip-plugins[=<plugins>], --skip-themes[=<themes>], --skip-packages

Do not load all, or the listed, plugins or themes (must-use plugins still load), or WP-CLI packages; the rescue path when one of them fatals

--require=<path>, --exec=<php-code>

Load a file, or run PHP, before the command

--context=<context>

auto (default), cli, admin (2.6+)

--[no-]color, --quiet, --debug[=<group>], --prompt

Output control (--debug=bootstrap shows how WP-CLI found its config); ask for argument values interactively

--skip-plugins also removes plugin commands: wp --skip-plugins lumen import answered "'lumen' is not a registered wp command". Environment variables: WP_CLI_PACKAGES_DIR and WP_CLI_CACHE_DIR move folders, WP_CLI_STRICT_ARGS_MODE=1 makes arguments after the command local (wp option get home --user=admin then fails with "unknown --user parameter"), and WP_CLI_SSH_PRE_CMD runs a command on the remote side first.

Aliases and remote commands

An alias names a site; wp @staging <command> runs the command there. For an SSH alias, WP-CLI starts ssh, changes to the alias path and runs the remote wp, which must be installed on the server and on the non-interactive shell’s PATH (~/bin usually is; ~/.bashrc is not read). Keys come from the agent or ~/.ssh/config; an alias may also set key (passed as ssh -i) and proxyjump (passed as ssh -J). Besides SSH, the ssh value accepts the schemes docker:<container>, docker-compose:<service>, docker-compose-run:<service> and vagrant.

$ scp books.csv deploy@staging.lumenpress.example:/tmp/books.csv
$ wp @staging lumen import /tmp/books.csv --user=lena.catalogue
Success: Imported 50 books, skipped 0.
$ wp @staging --debug option get home 2>&1 | grep 'Running SSH'
Debug (bootstrap): Running SSH command: ssh -T -vvv 'deploy@staging.lumenpress.example' 'cd '\''/srv/www/staging.lumenpress.example'\''; WP_CLI_RUNTIME_ALIAS=... wp @staging '\''--debug'\'' '\''option'\'' '\''get'\'' '\''home'\''' (0.03s)
$ wp @remote option get home; echo "exit=$?"
@staging
https://staging.lumenpress.example
@production
Error: Cannot connect over SSH using provided configuration.
exit=0

File arguments are remote paths, so the CSV is copied first; global parameters such as --user travel with the command. An alias group runs every member in turn and, in 2.12, exited 0 although production failed, so deployment scripts loop over single aliases and check each exit code instead of trusting @remote (or the implicit @all). wp cli alias list, get, add, update, delete and is-group manage aliases from the shell.

The most-used commands

Every command accepts --help; most list commands accept --format=table|csv|json|yaml|ids|count, --fields and --field, and create commands accept --porcelain to print only the new ID, which is how commands are chained: wp post delete $(wp post list --post_type=lp_book --post_status=trash --format=ids) --force. A command exits 0 on success and 1 on error, so set -e scripts stop at the first failure. The pages of this documentation already show each family on a real task; this table points to them.

Commands Typical tasks Shown in

wp core

download, install, version, check-update, update, update-db, verify-checksums, multisite-convert

Installation, Updates and Auto-Updates, Security Hardening

wp config

create, set, get, shuffle-salts, path

wp-config.php Reference

wp plugin, wp theme

install, activate, list, update, auto-updates, verify-checksums, uninstall; wp plugin check (Plugin Check plugin)

Choosing and Managing Plugins and Themes, Plugin Fundamentals, Themes Overview

wp user, wp role, wp cap, wp super-admin

Create users, update --user_pass, reset-password, session destroy, application-password, user meta, custom roles

Users, Roles and Profiles, Roles and Capabilities API, Authentication and Login API

wp post, wp post meta, wp post term

Create and list content, seed test data, inspect meta

Posts and Pages, Metadata and Custom Fields

wp term, wp comment

Taxonomy terms, comment moderation

Categories, Tags and Comments, Custom Post Types and Taxonomies

wp option

get, update, list --autoload=on, patch

Options, Transients and Object Cache, Settings Screens

wp db

export, import, query, tables, size, optimize

Database Schema, Backups and Disaster Recovery

wp search-replace

Change URLs safely in serialized data, --dry-run, --all-tables, --export

Importing, Exporting and Migrating

wp cron

event list, event run --due-now, test; in 2.12 there is no --network option and --due-now ignores the cron lock

WP-Cron and Scheduled Tasks

wp cache, wp transient

cache flush, cache type, transient delete --expired

Options, Transients and Object Cache, Performance and Caching

wp rewrite

structure, flush, list --match

Rewrite API and Permalinks

wp media

import, regenerate --only-missing

Media Library

wp i18n, wp language

make-pot, make-json, make-mo; install and activate translations

Internationalization

wp scaffold

child-theme, plugin, block, plugin-tests

Child Themes, Testing

wp site, wp network

Create, list and archive sites, network meta

Multisite Networks, Multisite Development

wp export, wp import

WXR export and import (the importer plugin is needed for import)

Importing, Exporting and Migrating

wp menu, wp widget, wp sidebar

Classic menus and widget areas

Navigation and Menus, Widgets and Sidebars

wp maintenance-mode

activate, deactivate, status around risky changes

Updates and Auto-Updates

wp eval, wp eval-file, wp shell

Run PHP inside a loaded WordPress: inspection, one-off fixes, debugging

Hooks: Actions and Filters, Debugging, Site Health and Troubleshooting

wp server

PHP’s built-in web server on the current install, for quick checks

Local Development Environments

wp cli, wp help, wp package

Version, info, update, aliases, has-command; packages (below)

This page

Source: WP-CLI Commands

Packages

Packages are to WP-CLI what plugins are to WordPress: Composer packages of type wp-cli-package that add commands, are installed per shell user into ~/.wp-cli/packages/ (WP_CLI_PACKAGES_DIR moves it) and load on every wp run until --skip-packages is passed. They come from the package index, a Git URL, a local folder or a ZIP file, and a version constraint follows a colon.

$ php -d memory_limit=512M "$(command -v wp)" package install wp-cli/doctor-command
  - wp-cli/doctor-command dev-main requires wp-cli/wp-cli ^3.0 -> found wp-cli/wp-cli[2.12.0] but it does not match the constraint.
Error: Package installation failed (Composer return code 2).
$ php -d memory_limit=512M "$(command -v wp)" package install wp-cli/doctor-command:^2.3
Success: Package installed.
$ wp doctor check autoload-options-size constant-wp-debug-falsy core-verify-checksums
name	status	message
core-verify-checksums	success	WordPress verifies against its checksums.
autoload-options-size	success	Autoloaded options size (37.18kb) is less than threshold (900kb).
constant-wp-debug-falsy	success	Constant 'WP_DEBUG' is defined falsy.

Without a constraint, 2.12 resolved the package to dev-main, whose main branch (like those of the other official packages) requires the unreleased WP-CLI 3. wp-cli/dist-archive-command 3.2 does too, so Publishing to the Plugin Directory pins 3.1.0; until 3.0 ships, every install names a 2.12-compatible version. Composer inside the Phar needs more than 128 MB of memory. Some doctor checks load the front-end template; here a canonical redirect printed an "Incomplete check execution" warning above the table (cut) without failing a check. A package is arbitrary PHP running as the shell user on every command and site, so only reviewed, pinned packages belong there. wp package list, update, uninstall and path manage them; wp scaffold package (from wp-cli/scaffold-package-command) generates a new one.

Writing a custom command: wp lumen import

The Lumen Press catalogue team receives new titles as CSV files. wp lumen import <file> reads a UTF-8 CSV with the header title,isbn,pages,published_on,genre, validates each row (title, ISBN-13 check digit, page count, date), skips ISBNs already in the catalogue or earlier in the file, and creates books with lumen_catalog_import_book() of Hooks: Actions and Filters (so its lumen_catalog_* hooks fire) before storing lp_published_on and the lp_genre term. It offers --dry-run, --porcelain, --format, a progress bar and distinct exit codes.

Commands can be functions, closures or classes. In a class, every public method becomes a subcommand, unless the class has __invoke(), which makes the class itself the command. Registering the class under the two-word name lumen import creates the wp lumen namespace automatically and leaves room for later subcommands such as wp lumen sync-stock. The plugin registers it from Plugin::boot() only when WP-CLI is running:

wp-content/plugins/lumen-catalog/src/Plugin.php (in boot())
if ( defined( 'WP_CLI' ) && WP_CLI ) {
	\WP_CLI::add_command( 'lumen import', Cli\Import_Command::class );
}

The cli_init action is an equivalent hook. A plugin command exists only where the plugin is active; one that must run before WordPress loads (@when before_wp_load, ignored in plugins) or without the plugin belongs in a package. The class, autoloaded by Composer from src/Cli/:

wp-content/plugins/lumen-catalog/src/Cli/Import_Command.php
<?php
/**
 * The wp lumen import command.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog\Cli;

use WP_CLI;
use WP_CLI\Utils;

defined( 'ABSPATH' ) || exit;

/**
 * Imports books into the catalogue from a CSV file.
 */
final class Import_Command {

	// Columns the CSV header must contain, in any order.
	const COLUMNS = array( 'title', 'isbn', 'pages', 'published_on', 'genre' );

	/**
	 * Imports books from a CSV file.
	 *
	 * Every valid row becomes a published book. A row whose ISBN is already in the
	 * catalogue, or earlier in the same file, is skipped, so the command can run
	 * again after the invalid rows have been fixed.
	 *
	 * ## OPTIONS
	 *
	 * <file>
	 * : UTF-8 CSV file whose header is title,isbn,pages,published_on,genre.
	 *
	 * [--dry-run]
	 * : Validate every row and report the result without writing anything.
	 *
	 * [--porcelain]
	 * : Print only the IDs of the new books, one per line.
	 *
	 * [--format=<format>]
	 * : Format of the report of rows that were not imported.
	 * ---
	 * default: table
	 * options:
	 *   - table
	 *   - csv
	 *   - json
	 *   - yaml
	 * ---
	 *
	 * ## EXIT STATUS
	 *
	 * 0 when every row was imported or skipped, 1 when the file cannot be used,
	 * 2 when at least one row was invalid or could not be saved.
	 *
	 * @param string[] $args       Positional arguments: the CSV file.
	 * @param array    $assoc_args Associative arguments and flags.
	 */
	public function __invoke( $args, $assoc_args ) {
		list( $file ) = $args;
		$dry_run      = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false );
		$porcelain    = (bool) Utils\get_flag_value( $assoc_args, 'porcelain', false );
		$format       = Utils\get_flag_value( $assoc_args, 'format', 'table' );

		$rows = $this->read_csv( $file );

		if ( ! $dry_run && 0 === get_current_user_id() ) {
			WP_CLI::error( 'Books need an author: add --user=<login>.' );
		}

		$known    = $this->existing_isbns();
		$created  = array();
		$problems = array();
		$progress = $porcelain ? null : Utils\make_progress_bar( $dry_run ? 'Checking books' : 'Importing books', count( $rows ) );

		wp_defer_term_counting( true );

		foreach ( $rows as $line => $row ) {
			$error  = $this->validate( $row );
			$key    = self::digits( $row['isbn'] ?? '' );
			$report = array(
				'line'  => $line,
				'isbn'  => $row['isbn'] ?? '',
				'title' => $row['title'] ?? '',
			);

			if ( '' !== $error ) {
				$problems[] = $report + array(
					'result' => 'invalid',
					'reason' => $error,
				);
				$key        = '';
			} elseif ( isset( $known[ $key ] ) ) {
				$problems[] = $report + array(
					'result' => 'skipped',
					'reason' => 'ISBN already in the catalogue',
				);
			} elseif ( ! $dry_run ) {
				$post_id = lumen_catalog_import_book( $row, 'csv' );

				if ( is_wp_error( $post_id ) ) {
					$problems[] = $report + array(
						'result' => 'failed',
						'reason' => $post_id->get_error_message(),
					);
				} else {
					update_post_meta( $post_id, 'lp_published_on', $row['published_on'] );
					wp_set_object_terms( $post_id, $row['genre'], 'lp_genre' );
					$created[] = $post_id;
				}
			}

			if ( '' !== $key ) {
				$known[ $key ] = true;
			}
			if ( $progress ) {
				$progress->tick();
			}
		}

		wp_defer_term_counting( false );

		if ( $progress ) {
			$progress->finish();
		}

		if ( $porcelain ) {
			array_map( array( WP_CLI::class, 'line' ), $created );
		} elseif ( $problems ) {
			Utils\format_items( $format, $problems, array( 'line', 'isbn', 'title', 'result', 'reason' ) );
		}

		$bad = count( wp_list_filter( $problems, array( 'result' => 'skipped' ), 'NOT' ) );

		if ( $bad > 0 ) {
			WP_CLI::error( sprintf( '%d of %d rows could not be imported.', $bad, count( $rows ) ), 2 );
		}

		if ( ! $porcelain ) {
			WP_CLI::success(
				sprintf(
					$dry_run ? 'Dry run: %d rows would be imported, %d skipped.' : 'Imported %d books, skipped %d.',
					count( $rows ) - count( $problems ),
					count( $problems )
				)
			);
		}
	}

	/**
	 * Reads the CSV file into rows keyed by line number; stops on unusable files.
	 *
	 * @param string $file Path to the file.
	 * @return array<int, array<string, string>> Rows; a malformed row holds only an `error` key.
	 */
	private function read_csv( $file ) {
		if ( ! is_file( $file ) || ! is_readable( $file ) ) {
			WP_CLI::error( "Cannot read {$file}." );
		}

		$csv = new \SplFileObject( $file );
		$csv->setFlags( \SplFileObject::READ_CSV | \SplFileObject::SKIP_EMPTY | \SplFileObject::READ_AHEAD );

		$header = null;
		$rows   = array();
		$line   = 0;

		foreach ( $csv as $fields ) {
			++$line;
			$fields = array_map( 'trim', $fields );

			if ( null === $header ) {
				$fields[0] = preg_replace( '/^\xEF\xBB\xBF/', '', $fields[0] ); // UTF-8 byte order mark.
				$header    = array_map( 'strtolower', $fields );
				$missing   = array_diff( self::COLUMNS, $header );
				if ( $missing ) {
					WP_CLI::error( 'Missing columns: ' . implode( ', ', $missing ) . '.' );
				}
				continue;
			}

			$rows[ $line ] = count( $fields ) === count( $header )
				? array_combine( $header, $fields )
				: array( 'error' => sprintf( 'Expected %d fields, found %d', count( $header ), count( $fields ) ) );
		}

		if ( ! $rows ) {
			WP_CLI::error( "{$file} has no data rows." );
		}

		return $rows;
	}

	/**
	 * Checks one row.
	 *
	 * @param array<string, string> $row Row from the CSV file.
	 * @return string Error message, or an empty string when the row is valid.
	 */
	private function validate( array $row ) {
		if ( isset( $row['error'] ) ) {
			return $row['error'];
		}
		if ( '' === $row['title'] ) {
			return 'Empty title';
		}

		$digits = str_split( self::digits( $row['isbn'] ) );
		$sum    = 0;
		foreach ( array_slice( $digits, 0, 12 ) as $i => $digit ) {
			$sum += (int) $digit * ( 0 === $i % 2 ? 1 : 3 );
		}
		if ( 13 !== count( $digits ) || ( 10 - $sum % 10 ) % 10 !== (int) $digits[12] ) {
			return 'Invalid ISBN-13';
		}

		if ( ! ctype_digit( $row['pages'] ) || (int) $row['pages'] < 1 ) {
			return 'Pages must be a positive whole number';
		}

		$date = \DateTime::createFromFormat( '!Y-m-d', $row['published_on'] );
		if ( ! $date || $date->format( 'Y-m-d' ) !== $row['published_on'] ) {
			return 'published_on must be YYYY-MM-DD';
		}
		return '';
	}

	/**
	 * Strips everything but digits, so 978-1-23456-789-7 and 9781234567897 match.
	 *
	 * @param string $isbn ISBN as written.
	 * @return string Digits only.
	 */
	private static function digits( $isbn ) {
		return preg_replace( '/\D/', '', (string) $isbn );
	}

	/**
	 * Loads the ISBNs already in the catalogue, in one query.
	 *
	 * @return array<string, true> Digits-only ISBNs as keys.
	 */
	private function existing_isbns() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one read per run, a cache would be stale.
		$isbns = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT pm.meta_value FROM {$wpdb->postmeta} pm
				INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
				WHERE pm.meta_key = %s AND p.post_type = %s AND p.post_status <> %s",
				'lp_isbn',
				'lp_book',
				'trash'
			)
		);

		return array_fill_keys( array_map( array( self::class, 'digits' ), $isbns ), true );
	}
}

The docblock is the synopsis

WP-CLI parses the method’s PHPDoc: the first line is the short description, the rest up to the first @ tag is the long description shown by wp help, and the argument tokens in it form the synopsis, which WP-CLI validates before the method runs. The command never sees an unknown flag or a format outside its list: --format=xml failed with "Invalid value specified for 'format'" and exit code 1. The @param tags WPCS asks for are not shown in the help.

<file> is a required positional argument (<file>... repeats it, [<file>] makes it optional); [--dry-run] is an optional flag, true when given and false with --no-dry-run; [--format=<format>] is an optional value whose YAML block between --- lines sets default and options (without brackets it would be required). The tags @subcommand (a method cannot be called list), @alias and @when (packages only) complete the set.

The handbook also documents short-form argument aliases ([--dry-run|n]) and repeating associative arguments ([--status=<status>...] collected into an array). Neither works in 2.12: the first produced "invalid synopsis part" and "Too many positional arguments: -n", the second kept only the last value. They belong to the 3.0 line.

Running the command

wp help lumen import prints the docblock as NAME, DESCRIPTION, SYNOPSIS (wp lumen import <file> [--dry-run] [--porcelain] [--format=<format>]), the long description, OPTIONS and EXIT STATUS, then the global parameters. The test file books.csv holds 50 books, among them "The Lighthouse Ledger" (978-1-23456-789-7), "The Glass Orchard" and "The Quiet Lighthouse", checked first and then imported:

$ wp lumen import books.csv
Error: Books need an author: add --user=<login>.
$ wp lumen import books.csv --dry-run
Checking books  100% [=====================================================] 0:00 / 0:00
Success: Dry run: 50 rows would be imported, 0 skipped.
$ wp lumen import books.csv --user=lena.catalogue
Importing books  100% [====================================================] 0:00 / 0:00
Success: Imported 50 books, skipped 0.

All 50 books belong to user 4 (lena.catalogue), and a second run skipped all 50 rows and exited 0. wp term list lp_genre showed six genres whose counts add up to 50 although counting was deferred during the loop (wp_defer_term_counting( false ) recounts once at the end). A second file, autumn.csv, mixes one new title with a duplicate and three broken rows:

$ wp lumen import autumn.csv --dry-run; echo "exit=$?"
+------+-------------------+---------------------+---------+---------------------------------------+
| line | isbn              | title               | result  | reason                                |
+------+-------------------+---------------------+---------+---------------------------------------+
| 3    | 978-1-23456-101-7 | The Glass Orchard   | skipped | ISBN already in the catalogue         |
| 4    | 978-1-23456-202-0 | The Northern Weaver | invalid | Invalid ISBN-13                       |
| 5    | 978-1-23456-203-8 | The Velvet Quarry   | invalid | Pages must be a positive whole number |
| 6    | 978-1-23456-204-5 | The Salt Kestrel    | invalid | published_on must be YYYY-MM-DD       |
+------+-------------------+---------------------+---------+---------------------------------------+
Error: 3 of 5 rows could not be imported.
exit=2
$ wp lumen import autumn.csv --user=lena.catalogue --porcelain; echo "exit=$?"
362
Error: 3 of 5 rows could not be imported.
exit=2

The valid row of autumn.csv, "The Patient Cartographer", became book 362 even though the run as a whole failed: the command imports what it can, reports the rest and signals the failure through the exit code. A missing file ("Cannot read missing.csv.") or one without the required columns ("Missing columns: pages, published_on, genre.") stops before any write with exit code 1.

Conventions the command follows

Concern How

Streams

Data (WP_CLI::line(), format_items()) goes to standard output; WP_CLI::warning() and WP_CLI::error() to standard error; WP_CLI::log() and WP_CLI::success() are informational and --quiet silences them, so pipes stay clean

Exit codes

WP_CLI::error( $message ) exits with 1; WP_CLI::error( $message, 2 ) (or WP_CLI::halt( 2 )) picks another code, here "partial failure" as opposed to "could not start"

--porcelain

Only the new IDs, one per line, no progress bar and no success line; the convention of core’s create commands, for xargs and $( )

--format, progress

WP_CLI\Utils\format_items() renders table, CSV, JSON or YAML from one array (a table on a terminal, tab-separated text in a pipe); WP_CLI\Utils\make_progress_bar() draws only on a terminal, so cron jobs and CI logs stay clean

Authorship

wp_insert_post() uses the current user as author; the command refuses to write without --user rather than create books with author 0

Long imports need more care: WordPress caches every post it touches for the life of the process, so very large files are split into separate runs, and a run of minutes belongs in its own scheduled command rather than a WP-Cron event (see Scaling and High Availability). In containers, php -l (PHP 8.4 and 7.4) and PHP_CodeSniffer with WPCS 3.4.1 and the ruleset of Plugin Fundamentals reported nothing; the direct query carries a reasoned phpcs:ignore, and SplFileObject avoids the WordPress.WP.AlternativeFunctions warnings for fopen().

Calling commands from code: WP_CLI::runcommand()

WP_CLI::runcommand() runs another command, in a child process by default ('launch' => true), which matters: WP_CLI::error() exits the process it runs in, so only a child process can fail without ending the caller. This script imports every CSV file of a folder and keeps going past a failed file:

import-folder.php
<?php
/**
 * Imports every CSV file of a folder, one child process per file.
 *
 * Usage: wp eval-file import-folder.php imports --user=lena.catalogue
 *
 * @package Lumen\Catalog
 */

$lumen_catalog_dir    = rtrim( $args[0] ?? 'imports', '/' );
$lumen_catalog_failed = 0;

foreach ( glob( $lumen_catalog_dir . '/*.csv' ) as $lumen_catalog_file ) {
	$lumen_catalog_run = WP_CLI::runcommand(
		'lumen import ' . escapeshellarg( $lumen_catalog_file ) . ' --porcelain',
		array(
			'return'     => 'all',   // Object with stdout, stderr and return_code.
			'exit_error' => false,   // Keep going when one file fails.
		)
	);
	$lumen_catalog_ids = array_filter( explode( "\n", $lumen_catalog_run->stdout ) );

	WP_CLI::log( sprintf( '%s: exit %d, %d new books', basename( $lumen_catalog_file ), $lumen_catalog_run->return_code, count( $lumen_catalog_ids ) ) );

	if ( 0 !== $lumen_catalog_run->return_code ) {
		WP_CLI::warning( trim( $lumen_catalog_run->stderr ) );
		++$lumen_catalog_failed;
	}
}

if ( $lumen_catalog_failed ) {
	WP_CLI::error( "{$lumen_catalog_failed} file(s) had rows that were not imported.", 2 );
}
WP_CLI::success( 'All files imported.' );
$ wp eval-file import-folder.php imports --user=lena.catalogue; echo "exit=$?"
2026-11-lumen-kids.csv: exit 0, 2 new books
2026-12-autumn.csv: exit 2, 0 new books
Warning: Error: 3 of 5 rows could not be imported.
Error: 1 file(s) had rows that were not imported.
exit=2

The children inherited --user=lena.catalogue (the Lumen Kids books belong to her). wp eval-file passes the arguments after the file as $args. 'return' => true returns only standard output, 'parse' => 'json' decodes it, and 'launch' => false reuses the process, faster but fatal on any error. wp cli has-command "lumen import" tests through its exit code whether a command exists (0 here, 1 under --skip-plugins).

Testing commands with Behat

WP-CLI tests its commands with Behat scenarios that run real wp processes against a fresh install and check output and exit codes. wp scaffold package-tests (from wp-cli/scaffold-package-command) generates the harness for a package (wp-cli/wp-cli-tests, features/, CI configuration). A scenario for the import command could read:

features/lumen-import.feature
Feature: Import books from a CSV file
  Scenario: Broken rows are reported with exit code 2
    Given a WP install
    And I run `wp plugin activate lumen-catalog`
    And a books.csv file:
      """
      title,isbn,pages,published_on,genre
      "The Lighthouse Ledger",978-1-23456-789-7,182,2022-11-19,Fiction
      "The Velvet Quarry",978-1-23456-203-8,many,2026-12-03,Crime
      """
    When I try `wp lumen import books.csv --user=admin --porcelain`
    Then the return code should be 2
    And STDERR should contain:
      """
      1 of 2 rows could not be imported.
      """

I try accepts a non-zero exit code where I run fails the step; the steps exist in wp-cli-tests, and the plugin must first be placed in the test install. The scenario was not run here: the current wp-cli-tests release line requires WP-CLI 3, and the scaffolding targets packages. Plugin logic is better tested with PHPUnit and the WordPress test suite (wp scaffold plugin-tests, see Testing).

Security and common pitfalls

Symptom or risk Cause and fix

"This does not seem to be a WordPress installation"

Wrong directory, or a wp-cli.local.yml without _: inherit: wp-cli.yml hid the shared path; wp cli info shows the project config in use

Files the web server cannot write after wp plugin install

WP-CLI ran as another user (root with --allow-root, UID 82 in the Alpine image); run it as the web server user

A domain change broke widgets or options

SQL REPLACE() or an editor broke serialized lengths; use wp search-replace, with --dry-run first

A script reports success although production failed

Alias groups exit 0 when a member fails; loop over single aliases

Secrets in shell history

--dbpass=... and --admin_password=... land in history and ps; use --prompt=dbpass, environment variables or configuration files with restricted permissions

Corrections to older books and tutorials

WordPress All-in-One For Dummies does not cover WP-CLI. Resetting a lost password by writing an MD5 hash in phpMyAdmin, common in older guides, still works because WordPress 6.8+ rehashes it with bcrypt at the next login, but wp user update <user> --user_pass=<password> or wp user reset-password <user> is the supported route. Ultimate WordPress Handbook covers WP-CLI basics only; configuration files, aliases, packages and custom commands are here. Older tutorials predate --context (2.6, default auto since 2.7), assume wp-cli.local.yml adds to wp-cli.yml rather than replacing it, and run wp package install <name> without a version, which now fails for many official packages. The online handbook tracks the 3.0 branch: its PHP 7.2.24 requirement and argument aliases are not 2.12.