From a92a4d47dc5e73d44f961dd63f01592d39fa6066 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:30:52 +0000 Subject: [PATCH 1/6] Flag commands and options not in the latest stable release The handbook is generated from the nightly build, so it can document commands and options that are not released yet (e.g. `wp block`). `wp handbook gen-commands` / `gen-all` now accept `--stable-phar=`. When given, the generator dumps the commands of the stable Phar and adds a note to every command page that is missing from it, or that documents options missing from it, pointing to the nightly build. Commands that already accepted arbitrary options in the stable release are not flagged for newly documented options. Unreleased commands are also marked with `"unreleased": true` in the commands manifest. The regenerate workflow downloads the latest stable Phar and passes it. See #666. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RVPkwDuM5DHXdSF4YW4qqr --- .github/workflows/regenerate-handbook.yml | 5 +- bin/Handbook_Command.php | 189 +++++++++++++++++++++- bin/templates/subcmd-list.mustache | 4 +- 3 files changed, 194 insertions(+), 4 deletions(-) diff --git a/.github/workflows/regenerate-handbook.yml b/.github/workflows/regenerate-handbook.yml index c558c3eb..7a7159f7 100644 --- a/.github/workflows/regenerate-handbook.yml +++ b/.github/workflows/regenerate-handbook.yml @@ -33,11 +33,14 @@ jobs: sudo mv wp-cli-nightly.phar /usr/local/bin/wp sudo chmod +x /usr/local/bin/wp + - name: Download latest stable WP-CLI for comparison + run: curl -sSfL -o "$RUNNER_TEMP/wp-cli-stable.phar" https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar + - name: Install non-bundled packages run: bash bin/install_packages.sh - name: Generate handbook - run: wp handbook gen-all + run: wp handbook gen-all --stable-phar="$RUNNER_TEMP/wp-cli-stable.phar" env: WP_CLI_PACKAGES_DIR: bin/packages WP_CLI_CONFIG_PATH: /dev/null diff --git a/bin/Handbook_Command.php b/bin/Handbook_Command.php index c8385efe..312db0a5 100644 --- a/bin/Handbook_Command.php +++ b/bin/Handbook_Command.php @@ -14,6 +14,29 @@ * @when before_wp_load */ class Handbook_Command { + /** + * Sentence used to flag commands missing from the latest stable release. + * + * Also used to detect such commands when generating the commands manifest. + */ + const UNRELEASED_COMMAND_NOTE = 'This command is not yet available in the latest stable release of WP-CLI'; + + /** + * Version of the latest stable WP-CLI release, if a stable Phar was given. + * + * @var string|null + */ + private static $stable_version = null; + + /** + * Commands available in the latest stable release, mapped to their options. + * + * `generic` is true if the command accepts arbitrary options, e.g. `[--=]`. + * + * @var array|null + */ + private static $stable_commands = null; + /** * @return string */ @@ -29,10 +52,13 @@ protected static function get_handbook_path() { * [--verbose] * : If set will list command pages as they are generated. * + * [--stable-phar=] + * : Path to the latest stable WP-CLI Phar. If set, commands and options missing from it are flagged as unreleased. + * * @subcommand gen-all * * @param string[] $args Positional arguments. Unused. - * @param array{verbose?: bool} $assoc_args Associative arguments. + * @param array{verbose?: bool, stable-phar?: string} $assoc_args Associative arguments. * @return void */ public function gen_all( $args, $assoc_args ) { @@ -236,10 +262,13 @@ function ( $parameter ) { * [--verbose] * : If set will list command pages as they are generated. * + * [--stable-phar=] + * : Path to the latest stable WP-CLI Phar. If set, commands and options missing from it are flagged as unreleased. + * * @subcommand gen-commands * * @param string[] $args Positional arguments. Unused. - * @param array{verbose?: bool} $assoc_args Associative arguments. + * @param array{verbose?: bool, stable-phar?: string} $assoc_args Associative arguments. * @return void */ public function gen_commands( $args, $assoc_args ) { @@ -258,6 +287,11 @@ public function gen_commands( $args, $assoc_args ) { WP_CLI::error( sprintf( "Install non-bundled packages by running '%s' first.", 'bin/install_packages.sh' ) ); } + $stable_phar = Utils\get_flag_value( $assoc_args, 'stable-phar' ); + if ( $stable_phar ) { + self::load_stable_reference( $stable_phar ); + } + self::empty_dir( self::get_handbook_path() . '/commands/' ); $wp = WP_CLI::runcommand( @@ -281,6 +315,153 @@ public function gen_commands( $args, $assoc_args ) { WP_CLI::success( 'Generated all command pages.' ); } + /** + * Loads the commands and options available in the latest stable release. + * + * @param string $phar Path to the stable WP-CLI Phar. + * @return void + */ + private static function load_stable_reference( $phar ) { + if ( ! is_file( $phar ) ) { + WP_CLI::error( "Stable Phar not found: {$phar}" ); + } + + $version = self::run_phar( $phar, 'cli version' ); + if ( ! preg_match( '/(\d+\.\d+\.\d+)/', $version, $matches ) ) { + WP_CLI::error( "Could not determine version of stable Phar: {$version}" ); + } + self::$stable_version = $matches[1]; + + $dump = json_decode( self::run_phar( $phar, 'cli cmd-dump' ), true ); + if ( ! is_array( $dump ) || ! isset( $dump['subcommands'] ) || ! is_array( $dump['subcommands'] ) ) { + WP_CLI::error( 'Could not dump commands of stable Phar.' ); + } + + self::$stable_commands = []; + foreach ( $dump['subcommands'] as $cmd ) { + self::flatten_cmd_dump( $cmd, [], self::$stable_commands ); + } + + WP_CLI::log( sprintf( 'Comparing against WP-CLI %s (%d commands).', self::$stable_version, count( self::$stable_commands ) ) ); + } + + /** + * Runs a WP-CLI command using the given Phar, inheriting the current environment. + * + * @param string $phar Path to the WP-CLI Phar. + * @param string $command WP-CLI command to run, without the leading `wp`. + * @return string Trimmed STDOUT. + */ + private static function run_phar( $phar, $command ) { + $stderr_file = (string) tempnam( sys_get_temp_dir(), 'wp-cli-handbook' ); + $process = proc_open( + Utils\esc_cmd( '%s %s', PHP_BINARY, $phar ) . ' ' . $command, + [ + 1 => [ 'pipe', 'w' ], + 2 => [ 'file', $stderr_file, 'w' ], + ], + $pipes + ); + if ( ! is_resource( $process ) ) { + WP_CLI::error( "Could not run '{$command}' using {$phar}." ); + } + $stdout = (string) stream_get_contents( $pipes[1] ); + fclose( $pipes[1] ); + $return_code = proc_close( $process ); + $stderr = (string) file_get_contents( $stderr_file ); + unlink( $stderr_file ); + + if ( 0 !== $return_code ) { + WP_CLI::error( "Running '{$command}' using {$phar} failed: {$stderr}" ); + } + + return trim( $stdout ); + } + + /** + * Flattens a `cli cmd-dump` command tree into a list of command paths and their options. + * + * Aliases are not included, as older versions of `cli cmd-dump` don't list them. + * Alias pages reuse the note of the command they point to instead. + * + * @param array $cmd Command as returned by `cli cmd-dump`. + * @param string[] $parent Names of the parent commands. + * @param array $out Command paths mapped to their options. + * @return void + */ + private static function flatten_cmd_dump( $cmd, $parent, &$out ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.parentFound + $synopsis = isset( $cmd['synopsis'] ) ? (string) $cmd['synopsis'] : ''; + $options = [ + 'options' => self::get_option_names( $synopsis ), + 'generic' => in_array( 'generic', array_column( WP_CLI\SynopsisParser::parse( $synopsis ), 'type' ), true ), + ]; + + $out[ implode( ' ', array_merge( $parent, [ (string) $cmd['name'] ] ) ) ] = $options; + + if ( isset( $cmd['subcommands'] ) && is_array( $cmd['subcommands'] ) ) { + foreach ( $cmd['subcommands'] as $subcmd ) { + self::flatten_cmd_dump( $subcmd, array_merge( $parent, [ (string) $cmd['name'] ] ), $out ); + } + } + } + + /** + * Extracts the option names (e.g. `--format`) from a command synopsis. + * + * @param string $synopsis Command synopsis. + * @return string[] + */ + private static function get_option_names( $synopsis ) { + $options = []; + foreach ( WP_CLI\SynopsisParser::parse( $synopsis ) as $param ) { + if ( in_array( $param['type'], [ 'assoc', 'flag' ], true ) && isset( $param['name'] ) ) { + $options[] = '--' . $param['name']; + } + } + return $options; + } + + /** + * Builds a note about parts of a command that are not in the latest stable release yet. + * + * @param string $full_name Full command name, e.g. `block type list`. + * @param string $synopsis Command synopsis. + * @return string Markdown note, or an empty string if everything is released or no stable Phar was given. + */ + private static function get_release_note( $full_name, $synopsis ) { + if ( null === self::$stable_commands ) { + return ''; + } + + $nightly = 'To use %s now, [switch to the nightly build](https://make.wordpress.org/cli/handbook/guides/installing/#updating-wp-cli) with `wp cli update --nightly`. If you installed the command as a separate package, updating that package may be enough.'; + + if ( ! isset( self::$stable_commands[ $full_name ] ) ) { + return sprintf( + '> **Note:** %s (%s). %s', + self::UNRELEASED_COMMAND_NOTE, + self::$stable_version, + sprintf( $nightly, 'it' ) + ); + } + + // Commands accepting arbitrary options might have supported a newly documented option already. + if ( self::$stable_commands[ $full_name ]['generic'] ) { + return ''; + } + + $unreleased = array_diff( self::get_option_names( $synopsis ), self::$stable_commands[ $full_name ]['options'] ); + if ( empty( $unreleased ) ) { + return ''; + } + + return sprintf( + '> **Note:** The following options are not yet available in the latest stable release of WP-CLI (%s): %s. %s', + self::$stable_version, + '`' . implode( '`, `', $unreleased ) . '`', + sprintf( $nightly, 'them' ) + ); + } + /** * Update the commands data array with new data * @@ -397,6 +578,9 @@ public function gen_commands_manifest() { if ( ! empty( $commands_data[ $title ] ) ) { $manifest[ $cmd_path ] = array_merge( $manifest[ $cmd_path ], $commands_data[ $title ] ); } + if ( false !== strpos( $contents, self::UNRELEASED_COMMAND_NOTE ) ) { + $manifest[ $cmd_path ]['unreleased'] = true; + } } } file_put_contents( self::get_handbook_path() . '/bin/commands-manifest.json', json_encode( $manifest, JSON_PRETTY_PRINT ) ); @@ -593,6 +777,7 @@ private static function gen_cmd_pages( $cmd, $parent = [], $verbose = false ) { } } $binding['has-subcommands'] = isset( $cmd['subcommands'] ) ? [ true ] : false; + $binding['release-note'] = self::get_release_note( $binding['synopsis'], $cmd['synopsis'] ?? '' ); $hook_name = $cmd['hook']; $hook_description = $hook_name ? Utils\get_hook_description( $hook_name ) : null; diff --git a/bin/templates/subcmd-list.mustache b/bin/templates/subcmd-list.mustache index b8afc700..4326f714 100644 --- a/bin/templates/subcmd-list.mustache +++ b/bin/templates/subcmd-list.mustache @@ -2,6 +2,8 @@ {{description}}{{#alias}} -**Alias:** `{{alias}}`{{/alias}} +**Alias:** `{{alias}}`{{/alias}}{{#release-note}} + +{{{release-note}}}{{/release-note}} {{{docs}}} From 76c3ce41b0bb5744db1e9881ab630cb57cbb047d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:33:52 +0000 Subject: [PATCH 2/6] Document --stable-phar in README Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RVPkwDuM5DHXdSF4YW4qqr --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1bd0632d..a00bc072 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,13 @@ bin/install_packages.sh WP_CLI_PACKAGES_DIR=bin/packages WP_CLI_CONFIG_PATH=/dev/null wp handbook gen-all ``` +Since the command pages are generated from the nightly build, they can document commands and options that are not part of a stable release yet. To flag those on the generated pages, pass the latest stable Phar using `--stable-phar`: + +``` +curl -o /tmp/wp-cli-stable.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar +WP_CLI_PACKAGES_DIR=bin/packages WP_CLI_CONFIG_PATH=/dev/null wp handbook gen-all --stable-phar=/tmp/wp-cli-stable.phar +``` + ## Automated Handbook Generation The handbook can be regenerated automatically using the "Regenerate Handbook" GitHub Actions workflow. This workflow can be triggered in two ways: @@ -28,8 +35,9 @@ The handbook can be regenerated automatically using the "Regenerate Handbook" Gi The workflow will: - Install WP-CLI nightly build +- Download the latest stable WP-CLI release - Install non-bundled packages -- Run `wp handbook gen-all` +- Run `wp handbook gen-all --stable-phar=...`, flagging commands and options that are not in the latest stable release yet - Commit and push any changes to the repository ## Documentation Import From ff94b60162116c3849c797b4336b5a084f9a5312 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:34:47 +0000 Subject: [PATCH 3/6] Run the stable Phar outside of the handbook directory Avoids loading the handbook's wp-cli.yml (and with it the handbook's Composer autoloader) into the stable Phar, and keeps PHP notices out of the JSON output of `cli cmd-dump`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RVPkwDuM5DHXdSF4YW4qqr --- bin/Handbook_Command.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/bin/Handbook_Command.php b/bin/Handbook_Command.php index 312db0a5..fa4f0dc8 100644 --- a/bin/Handbook_Command.php +++ b/bin/Handbook_Command.php @@ -348,19 +348,29 @@ private static function load_stable_reference( $phar ) { /** * Runs a WP-CLI command using the given Phar, inheriting the current environment. * + * The command runs outside of the handbook directory, so that the handbook's `wp-cli.yml` isn't loaded. + * * @param string $phar Path to the WP-CLI Phar. * @param string $command WP-CLI command to run, without the leading `wp`. * @return string Trimmed STDOUT. */ private static function run_phar( $phar, $command ) { + $env = getenv(); + if ( ! empty( $env['WP_CLI_PACKAGES_DIR'] ) && realpath( $env['WP_CLI_PACKAGES_DIR'] ) ) { + $env['WP_CLI_PACKAGES_DIR'] = (string) realpath( $env['WP_CLI_PACKAGES_DIR'] ); + } + $stderr_file = (string) tempnam( sys_get_temp_dir(), 'wp-cli-handbook' ); $process = proc_open( - Utils\esc_cmd( '%s %s', PHP_BINARY, $phar ) . ' ' . $command, + // Keep PHP notices out of STDOUT, which might be JSON. + Utils\esc_cmd( '%s -d display_errors=stderr %s', PHP_BINARY, (string) realpath( $phar ) ) . ' ' . $command, [ 1 => [ 'pipe', 'w' ], 2 => [ 'file', $stderr_file, 'w' ], ], - $pipes + $pipes, + sys_get_temp_dir(), + $env ); if ( ! is_resource( $process ) ) { WP_CLI::error( "Could not run '{$command}' using {$phar}." ); From 3c5481f93d96cc569986f86dc4909dc49616f516 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:49:16 +0000 Subject: [PATCH 4/6] Reject prerelease Phars as stable reference Comparing against a nightly Phar would silently drop the notes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RVPkwDuM5DHXdSF4YW4qqr --- bin/Handbook_Command.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/Handbook_Command.php b/bin/Handbook_Command.php index fa4f0dc8..cdaccb97 100644 --- a/bin/Handbook_Command.php +++ b/bin/Handbook_Command.php @@ -327,8 +327,8 @@ private static function load_stable_reference( $phar ) { } $version = self::run_phar( $phar, 'cli version' ); - if ( ! preg_match( '/(\d+\.\d+\.\d+)/', $version, $matches ) ) { - WP_CLI::error( "Could not determine version of stable Phar: {$version}" ); + if ( ! preg_match( '/^WP-CLI (\d+\.\d+\.\d+)$/', $version, $matches ) ) { + WP_CLI::error( "Not a stable WP-CLI release: {$version}" ); } self::$stable_version = $matches[1]; From bcdca8bf80ec978163aecc473af64b9cd50d32fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 09:50:35 +0000 Subject: [PATCH 5/6] Drop the version number from the unreleased notes "Not available in the latest stable release (2.12.0)" reads as if the command should be in 2.12.0. Say it's not part of a stable release yet instead, which also keeps flagged pages from changing on every release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RVPkwDuM5DHXdSF4YW4qqr --- bin/Handbook_Command.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bin/Handbook_Command.php b/bin/Handbook_Command.php index cdaccb97..aaaab19b 100644 --- a/bin/Handbook_Command.php +++ b/bin/Handbook_Command.php @@ -19,7 +19,7 @@ class Handbook_Command { * * Also used to detect such commands when generating the commands manifest. */ - const UNRELEASED_COMMAND_NOTE = 'This command is not yet available in the latest stable release of WP-CLI'; + const UNRELEASED_COMMAND_NOTE = 'This command is not part of a stable WP-CLI release yet.'; /** * Version of the latest stable WP-CLI release, if a stable Phar was given. @@ -447,9 +447,8 @@ private static function get_release_note( $full_name, $synopsis ) { if ( ! isset( self::$stable_commands[ $full_name ] ) ) { return sprintf( - '> **Note:** %s (%s). %s', + '> **Note:** %s %s', self::UNRELEASED_COMMAND_NOTE, - self::$stable_version, sprintf( $nightly, 'it' ) ); } @@ -465,8 +464,7 @@ private static function get_release_note( $full_name, $synopsis ) { } return sprintf( - '> **Note:** The following options are not yet available in the latest stable release of WP-CLI (%s): %s. %s', - self::$stable_version, + '> **Note:** The following options are not part of a stable WP-CLI release yet: %s. %s', '`' . implode( '`, `', $unreleased ) . '`', sprintf( $nightly, 'them' ) ); From 8467924619d80dacbebbd7ea2103c97257389e6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:53:19 +0000 Subject: [PATCH 6/6] Compare option aliases too Nightly synopses can declare option aliases, e.g. `[--dbpass|db_password=]`. Treat every alias as an option name, so a newly added alias like `config create --db_password` is flagged as unreleased as well. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RVPkwDuM5DHXdSF4YW4qqr --- bin/Handbook_Command.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bin/Handbook_Command.php b/bin/Handbook_Command.php index aaaab19b..02c38249 100644 --- a/bin/Handbook_Command.php +++ b/bin/Handbook_Command.php @@ -418,6 +418,8 @@ private static function flatten_cmd_dump( $cmd, $parent, &$out ) { // phpcs:igno /** * Extracts the option names (e.g. `--format`) from a command synopsis. * + * Option aliases (e.g. `--dbpass|db_password`) are included as separate names. + * * @param string $synopsis Command synopsis. * @return string[] */ @@ -426,6 +428,9 @@ private static function get_option_names( $synopsis ) { foreach ( WP_CLI\SynopsisParser::parse( $synopsis ) as $param ) { if ( in_array( $param['type'], [ 'assoc', 'flag' ], true ) && isset( $param['name'] ) ) { $options[] = '--' . $param['name']; + foreach ( isset( $param['aliases'] ) ? (array) $param['aliases'] : [] as $alias ) { + $options[] = '--' . $alias; + } } } return $options;