@@ -13,12 +13,21 @@ class Shell_Command extends WP_CLI_Command {
1313 * is loaded, you have access to all the functions, classes and globals
1414 * that you can use within a WordPress plugin, for example.
1515 *
16+ * The `restart` command reloads the shell by spawning a new PHP process,
17+ * allowing modified code to be fully reloaded. Note that this requires
18+ * the `pcntl_exec()` function. If not available, the shell restarts
19+ * in-process, which resets variables but doesn't reload PHP files.
20+ *
1621 * ## OPTIONS
1722 *
1823 * [--basic]
1924 * : Force the use of WP-CLI's built-in PHP REPL, even if the Boris or
2025 * PsySH PHP REPLs are available.
2126 *
27+ * [--watch=<path>]
28+ * : Watch a file or directory for changes and automatically restart the shell.
29+ * Only works with the built-in REPL (--basic).
30+ *
2231 * [--hook=<hook>]
2332 * : Ensure that a specific WordPress action hook has fired before starting the shell.
2433 * This validates that the preconditions associated with that hook are met.
@@ -34,15 +43,37 @@ class Shell_Command extends WP_CLI_Command {
3443 * wp> get_bloginfo( 'name' );
3544 * => string(6) "WP-CLI"
3645 *
46+ * # Restart the shell to reload code changes.
47+ * $ wp shell
48+ * wp> restart
49+ * Restarting shell in new process...
50+ * wp>
51+ *
52+ * # Watch a directory for changes and auto-restart.
53+ * $ wp shell --watch=wp-content/plugins/my-plugin
54+ * wp> // Make changes to files in the plugin directory
55+ * Detected changes in wp-content/plugins/my-plugin, restarting shell...
56+ * wp>
57+ *
3758 * # Start a shell, ensuring the 'init' hook has already fired.
3859 * $ wp shell --hook=init
3960 *
4061 * # Start a shell in quiet mode, suppressing return value output.
4162 * $ wp shell --quiet
4263 * wp> $a = "hello";
4364 * wp>
65+ *
66+ * @param string[] $_ Positional arguments. Unused.
67+ * @param array{basic?: bool, watch?: string} $assoc_args Associative arguments.
4468 */
4569 public function __invoke ( $ _ , $ assoc_args ) {
70+ $ watch_path = Utils \get_flag_value ( $ assoc_args , 'watch ' , false );
71+
72+ if ( $ watch_path && ! Utils \get_flag_value ( $ assoc_args , 'basic ' ) ) {
73+ WP_CLI ::warning ( 'The --watch option only works with the built-in REPL. Enabling --basic mode. ' );
74+ $ assoc_args ['basic ' ] = true ;
75+ }
76+
4677 $ hook = Utils \get_flag_value ( $ assoc_args , 'hook ' , '' );
4778
4879 // No hook specified, start immediately.
@@ -72,9 +103,16 @@ public function __invoke( $_, $assoc_args ) {
72103 /**
73104 * Start the shell REPL.
74105 *
75- * @param array<string, bool| string> $assoc_args Associative arguments.
106+ * @param array{basic?: bool, watch?: string} $assoc_args Associative arguments.
76107 */
77108 private function start_shell ( $ assoc_args ) {
109+ $ watch_path = Utils \get_flag_value ( $ assoc_args , 'watch ' , '' );
110+
111+ if ( $ watch_path && ! Utils \get_flag_value ( $ assoc_args , 'basic ' ) ) {
112+ WP_CLI ::warning ( 'The --watch option only works with the built-in REPL. Enabling --basic mode. ' );
113+ $ assoc_args ['basic ' ] = true ;
114+ }
115+
78116 $ class = WP_CLI \Shell \REPL ::class;
79117 $ quiet = (bool ) WP_CLI ::get_config ( 'quiet ' );
80118
@@ -107,8 +145,116 @@ private function start_shell( $assoc_args ) {
107145 /**
108146 * @var class-string<WP_CLI\Shell\REPL> $class
109147 */
110- $ repl = new $ class ( 'wp> ' , $ quiet );
111- $ repl ->start ();
148+ if ( $ watch_path ) {
149+ $ watch_path = $ this ->resolve_watch_path ( $ watch_path );
150+ }
151+
152+ do {
153+ $ repl = new $ class ( 'wp> ' , $ quiet );
154+ if ( $ watch_path ) {
155+ $ repl ->set_watch_path ( $ watch_path );
156+ }
157+ $ exit_code = $ repl ->start ();
158+
159+ // If restart requested, exec a new PHP process to reload all code
160+ if ( WP_CLI \Shell \REPL ::EXIT_CODE_RESTART === $ exit_code ) {
161+ $ this ->restart_process ( $ assoc_args );
162+ // If restart_process() returns, pcntl_exec is not available, continue in-process
163+ }
164+ } while ( WP_CLI \Shell \REPL ::EXIT_CODE_RESTART === $ exit_code );
165+ }
166+ }
167+
168+ /**
169+ * Resolve and validate the watch path.
170+ *
171+ * @param string $path Path to watch.
172+ * @return string|never Absolute path to watch.
173+ */
174+ private function resolve_watch_path ( $ path ) {
175+ if ( ! file_exists ( $ path ) ) {
176+ WP_CLI ::error ( "Watch path does not exist: {$ path }" );
177+ }
178+
179+ $ realpath = realpath ( $ path );
180+ if ( false === $ realpath ) {
181+ WP_CLI ::error ( "Could not resolve watch path: {$ path }" );
182+ }
183+
184+ return $ realpath ;
185+ }
186+
187+ /**
188+ * Restart the shell by spawning a new PHP process.
189+ *
190+ * This replaces the current process with a new one to fully reload all code.
191+ * Falls back to in-process restart if pcntl_exec is not available.
192+ *
193+ * @param array{basic?: bool, watch?: string} $assoc_args Command arguments to preserve.
194+ */
195+ private function restart_process ( $ assoc_args ) {
196+ /**
197+ * @var array{0?: string} $argv
198+ */
199+ global $ argv ;
200+
201+ // Check if pcntl_exec is available
202+ if ( ! function_exists ( 'pcntl_exec ' ) ) {
203+ WP_CLI ::debug ( 'pcntl_exec not available, falling back to in-process restart ' , 'shell ' );
204+ return ;
205+ }
206+
207+ // Build the command to restart wp shell with the same arguments
208+ $ php_binary = Utils \get_php_binary ();
209+
210+ /**
211+ * @var array{argv: array{0?: string}} $_SERVER
212+ */
213+
214+ // Get the WP-CLI script path
215+ $ wp_cli_script = null ;
216+ if ( isset ( $ argv [0 ] ) ) {
217+ $ wp_cli_script = $ argv [0 ];
218+ } elseif ( isset ( $ _SERVER ['argv ' ][0 ] ) ) {
219+ $ wp_cli_script = $ _SERVER ['argv ' ][0 ];
112220 }
221+
222+ if ( ! $ wp_cli_script ) {
223+ WP_CLI ::debug ( 'Could not determine WP-CLI script path, falling back to in-process restart ' , 'shell ' );
224+ return ;
225+ }
226+
227+ // Build arguments array
228+ $ args = array ( $ php_binary , $ wp_cli_script , 'shell ' );
229+
230+ if ( Utils \get_flag_value ( $ assoc_args , 'basic ' ) ) {
231+ $ args [] = '--basic ' ;
232+ }
233+
234+ $ watch_path = Utils \get_flag_value ( $ assoc_args , 'watch ' , false );
235+ if ( $ watch_path ) {
236+ $ args [] = '--watch= ' . $ watch_path ;
237+ }
238+
239+ // Add global config values to preserve the environment after restart
240+ $ config = WP_CLI ::get_runner ()->config ;
241+ if ( isset ( $ config ['path ' ] ) ) {
242+ $ args [] = '--path= ' . $ config ['path ' ];
243+ }
244+ if ( isset ( $ config ['user ' ] ) ) {
245+ $ args [] = '--user= ' . $ config ['user ' ];
246+ }
247+ if ( isset ( $ config ['url ' ] ) ) {
248+ $ args [] = '--url= ' . $ config ['url ' ];
249+ }
250+
251+ WP_CLI ::log ( 'Restarting shell in new process... ' );
252+
253+ // Replace the current process with a new one
254+ // Note: pcntl_exec does not return on success
255+ pcntl_exec ( $ php_binary , array_slice ( $ args , 1 ) );
256+
257+ // If we reach here, exec failed
258+ WP_CLI ::warning ( 'Failed to restart process, falling back to in-process restart ' );
113259 }
114260}
0 commit comments