*/ public static function commands(): array { static $map = null; if ( null !== $map ) { return $map; } $map = array(); foreach ( Module_Registry::available() as $module ) { foreach ( (array) $module->cli_commands() as $cmd ) { $name = isset( $cmd['name'] ) ? (string) $cmd['name'] : ''; $callback = $cmd['callback'] ?? null; if ( '' === $name || ! is_callable( $callback ) ) { continue; } $map[ $name ] = array( 'callback' => $callback, 'shortdesc' => isset( $cmd['shortdesc'] ) ? (string) $cmd['shortdesc'] : '', 'synopsis' => isset( $cmd['synopsis'] ) && is_array( $cmd['synopsis'] ) ? $cmd['synopsis'] : array(), 'module' => $module->slug(), ); } } ksort( $map ); return $map; } /** * A catalog of the available commands for discovery (the run_command * tool advertises these so the AI knows what it can call). * * @return array */ public static function catalog(): array { $out = array(); foreach ( self::commands() as $name => $spec ) { $options = array(); foreach ( $spec['synopsis'] as $arg ) { if ( isset( $arg['name'] ) ) { $type = $arg['type'] ?? 'assoc'; $options[] = ( 'positional' === $type ? '<' . $arg['name'] . '>' : '--' . $arg['name'] ); } } $out[] = array( 'command' => $name, 'description' => $spec['shortdesc'], 'module' => $spec['module'], 'options' => $options, ); } return $out; } /** * Run a command by name. * * @param string $command Full command name, with or without the * leading "xspeed " (e.g. "cloudflare purge" * or "xspeed cloudflare purge"). * @param array $args Positional args. * @param array $assoc Named options / flags (e.g. ['url' => '…']). * @return array{command:string, ok:bool, output:string, lines:string[], error?:string}|\WP_Error */ public static function run( string $command, array $args = array(), array $assoc = array() ) { $input = self::normalize( $command ); $commands = self::commands(); // A registered command name may be a prefix (e.g. "xspeed db") with // the subcommand passed as a positional arg ("scan"). Resolve to the // LONGEST registered name that prefixes the input, and fold any // trailing words into the leading positional args. list( $name, $extra ) = self::resolve( $input, $commands ); if ( '' === $name ) { return new \WP_Error( 'xspeed_mcp_unknown_command', sprintf( /* translators: %s: command name. */ __( 'Unknown command: %s. Call list_commands to see what is available.', 'xspeed' ), $input ), array( 'status' => 404 ) ); } // Trailing words from the command string come before explicit args. $args = array_merge( $extra, array_values( $args ) ); $buffer = new Cli_Output_Buffer(); Cli_Shim::bind( $buffer ); $ok = true; $error = ''; try { call_user_func( $commands[ $name ]['callback'], $args, $assoc ); } catch ( Cli_Error_Signal $e ) { // \WP_CLI::error() was called — a controlled failure, not a fatal. $ok = false; $error = $e->getMessage(); } catch ( \Throwable $e ) { $ok = false; $error = $e->getMessage(); } finally { Cli_Shim::unbind(); } $result = array( 'command' => $name, 'ok' => $ok, 'output' => $buffer->text(), 'lines' => $buffer->lines(), ); if ( '' !== $error ) { $result['error'] = $error; } return $result; } /** * Normalize a command name: trim, collapse whitespace, and ensure the * "xspeed " namespace prefix so callers can pass either form. */ private static function normalize( string $command ): string { $command = trim( preg_replace( '/\s+/', ' ', $command ) ?? '' ); if ( '' === $command ) { return ''; } if ( 0 !== strpos( $command, 'xspeed ' ) && 'xspeed' !== $command ) { $command = 'xspeed ' . $command; } return $command; } /** * Resolve a normalized input string to the longest registered command * name that prefixes it, returning [name, trailing-words-as-args]. * Trailing words become leading positional args (e.g. "xspeed db scan" * → name "xspeed db", args ["scan"]). * * @param string $input Normalized command string. * @param array $commands Command map. * @return array{0:string,1:string[]} [name (''=unresolved), extra args] */ private static function resolve( string $input, array $commands ): array { // Exact match wins immediately. if ( isset( $commands[ $input ] ) ) { return array( $input, array() ); } $parts = explode( ' ', $input ); // Try progressively shorter prefixes; longest match first. for ( $i = count( $parts ); $i >= 1; $i-- ) { $candidate = implode( ' ', array_slice( $parts, 0, $i ) ); if ( isset( $commands[ $candidate ] ) ) { return array( $candidate, array_slice( $parts, $i ) ); } } return array( '', array() ); } }