@@ -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,10 +43,32 @@ 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
60+ *
61+ * @param string[] $_ Positional arguments. Unused.
62+ * @param array{basic?: bool, watch?: string} $assoc_args Associative arguments.
3963 */
4064 public function __invoke ( $ _ , $ assoc_args ) {
65+ $ watch_path = Utils \get_flag_value ( $ assoc_args , 'watch ' , false );
66+
67+ if ( $ watch_path && ! Utils \get_flag_value ( $ assoc_args , 'basic ' ) ) {
68+ WP_CLI ::warning ( 'The --watch option only works with the built-in REPL. Enabling --basic mode. ' );
69+ $ assoc_args ['basic ' ] = true ;
70+ }
71+
4172 $ hook = Utils \get_flag_value ( $ assoc_args , 'hook ' , '' );
4273
4374 // No hook specified, start immediately.
@@ -67,9 +98,16 @@ public function __invoke( $_, $assoc_args ) {
6798 /**
6899 * Start the shell REPL.
69100 *
70- * @param array<string, bool| string> $assoc_args Associative arguments.
101+ * @param array{basic?: bool, watch?: string} $assoc_args Associative arguments.
71102 */
72103 private function start_shell ( $ assoc_args ) {
104+ $ watch_path = Utils \get_flag_value ( $ assoc_args , 'watch ' , '' );
105+
106+ if ( $ watch_path && ! Utils \get_flag_value ( $ assoc_args , 'basic ' ) ) {
107+ WP_CLI ::warning ( 'The --watch option only works with the built-in REPL. Enabling --basic mode. ' );
108+ $ assoc_args ['basic ' ] = true ;
109+ }
110+
73111 $ class = WP_CLI \Shell \REPL ::class;
74112
75113 $ implementations = array (
@@ -98,8 +136,116 @@ private function start_shell( $assoc_args ) {
98136 /**
99137 * @var class-string<WP_CLI\Shell\REPL> $class
100138 */
101- $ repl = new $ class ( 'wp> ' );
102- $ repl ->start ();
139+ if ( $ watch_path ) {
140+ $ watch_path = $ this ->resolve_watch_path ( $ watch_path );
141+ }
142+
143+ do {
144+ $ repl = new $ class ( 'wp> ' );
145+ if ( $ watch_path ) {
146+ $ repl ->set_watch_path ( $ watch_path );
147+ }
148+ $ exit_code = $ repl ->start ();
149+
150+ // If restart requested, exec a new PHP process to reload all code
151+ if ( WP_CLI \Shell \REPL ::EXIT_CODE_RESTART === $ exit_code ) {
152+ $ this ->restart_process ( $ assoc_args );
153+ // If restart_process() returns, pcntl_exec is not available, continue in-process
154+ }
155+ } while ( WP_CLI \Shell \REPL ::EXIT_CODE_RESTART === $ exit_code );
156+ }
157+ }
158+
159+ /**
160+ * Resolve and validate the watch path.
161+ *
162+ * @param string $path Path to watch.
163+ * @return string|never Absolute path to watch.
164+ */
165+ private function resolve_watch_path ( $ path ) {
166+ if ( ! file_exists ( $ path ) ) {
167+ WP_CLI ::error ( "Watch path does not exist: {$ path }" );
168+ }
169+
170+ $ realpath = realpath ( $ path );
171+ if ( false === $ realpath ) {
172+ WP_CLI ::error ( "Could not resolve watch path: {$ path }" );
173+ }
174+
175+ return $ realpath ;
176+ }
177+
178+ /**
179+ * Restart the shell by spawning a new PHP process.
180+ *
181+ * This replaces the current process with a new one to fully reload all code.
182+ * Falls back to in-process restart if pcntl_exec is not available.
183+ *
184+ * @param array{basic?: bool, watch?: string} $assoc_args Command arguments to preserve.
185+ */
186+ private function restart_process ( $ assoc_args ) {
187+ /**
188+ * @var array{0?: string} $argv
189+ */
190+ global $ argv ;
191+
192+ // Check if pcntl_exec is available
193+ if ( ! function_exists ( 'pcntl_exec ' ) ) {
194+ WP_CLI ::debug ( 'pcntl_exec not available, falling back to in-process restart ' , 'shell ' );
195+ return ;
103196 }
197+
198+ // Build the command to restart wp shell with the same arguments
199+ $ php_binary = Utils \get_php_binary ();
200+
201+ /**
202+ * @var array{argv: array{0?: string}} $_SERVER
203+ */
204+
205+ // Get the WP-CLI script path
206+ $ wp_cli_script = null ;
207+ if ( isset ( $ argv [0 ] ) ) {
208+ $ wp_cli_script = $ argv [0 ];
209+ } elseif ( isset ( $ _SERVER ['argv ' ][0 ] ) ) {
210+ $ wp_cli_script = $ _SERVER ['argv ' ][0 ];
211+ }
212+
213+ if ( ! $ wp_cli_script ) {
214+ WP_CLI ::debug ( 'Could not determine WP-CLI script path, falling back to in-process restart ' , 'shell ' );
215+ return ;
216+ }
217+
218+ // Build arguments array
219+ $ args = array ( $ php_binary , $ wp_cli_script , 'shell ' );
220+
221+ if ( Utils \get_flag_value ( $ assoc_args , 'basic ' ) ) {
222+ $ args [] = '--basic ' ;
223+ }
224+
225+ $ watch_path = Utils \get_flag_value ( $ assoc_args , 'watch ' , false );
226+ if ( $ watch_path ) {
227+ $ args [] = '--watch= ' . $ watch_path ;
228+ }
229+
230+ // Add global config values to preserve the environment after restart
231+ $ config = WP_CLI ::get_runner ()->config ;
232+ if ( isset ( $ config ['path ' ] ) ) {
233+ $ args [] = '--path= ' . $ config ['path ' ];
234+ }
235+ if ( isset ( $ config ['user ' ] ) ) {
236+ $ args [] = '--user= ' . $ config ['user ' ];
237+ }
238+ if ( isset ( $ config ['url ' ] ) ) {
239+ $ args [] = '--url= ' . $ config ['url ' ];
240+ }
241+
242+ WP_CLI ::log ( 'Restarting shell in new process... ' );
243+
244+ // Replace the current process with a new one
245+ // Note: pcntl_exec does not return on success
246+ pcntl_exec ( $ php_binary , array_slice ( $ args , 1 ) );
247+
248+ // If we reach here, exec failed
249+ WP_CLI ::warning ( 'Failed to restart process, falling back to in-process restart ' );
104250 }
105251}
0 commit comments