PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.8
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Cli_Bridge.php

Cli_Bridge.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.0.8, at includes/modules/Mcp/Cli_Bridge.php

201 lines 6.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * CLI bridge — run any registered xSpeed WP-CLI command from a web
4 * (MCP) request, giving MCP 100% parity with the CLI surface through a
5 * single tool instead of ~50 hand-written ones.
6 *
7 * Every module declares its commands via Module::cli_commands()
8 * ({ name, callback, shortdesc, synopsis }). Those callbacks were
9 * written for WP-CLI: they emit output with \WP_CLI::log/success/error/
10 * warning and return void. In an MCP request WP_CLI isn't defined, so we
11 * can't just call a callback and read a return value.
12 *
13 * This bridge:
14 * 1. Builds a name → command map from every available module.
15 * 2. Installs a lightweight \WP_CLI shim (only when the real WP_CLI is
16 * absent) that BUFFERS log/success/warning and THROWS on error
17 * instead of printing/halting.
18 * 3. Invokes the matching callback with parsed (args, assoc), captures
19 * the buffered lines, and returns them as structured output.
20 *
21 * Because dispatch goes through the same cli_commands() callbacks the
22 * CLI uses, MCP coverage can never drift from the CLI — a new command is
23 * instantly reachable from both.
24 *
25 * @package XSpeed
26 */
27
28 declare(strict_types=1);
29
30 namespace XSpeed\Modules\Mcp;
31
32 use XSpeed\Module_Registry;
33
34 defined( 'ABSPATH' ) || exit;
35
36 final class Cli_Bridge {
37
38 /**
39 * Map of full command name (e.g. "xspeed cloudflare purge") to its
40 * descriptor. Built once per request from every available module.
41 *
42 * @return array<string, array{callback:callable, shortdesc:string, synopsis:array, module:string}>
43 */
44 public static function commands(): array {
45 static $map = null;
46 if ( null !== $map ) {
47 return $map;
48 }
49 $map = array();
50 foreach ( Module_Registry::available() as $module ) {
51 foreach ( (array) $module->cli_commands() as $cmd ) {
52 $name = isset( $cmd['name'] ) ? (string) $cmd['name'] : '';
53 $callback = $cmd['callback'] ?? null;
54 if ( '' === $name || ! is_callable( $callback ) ) {
55 continue;
56 }
57 $map[ $name ] = array(
58 'callback' => $callback,
59 'shortdesc' => isset( $cmd['shortdesc'] ) ? (string) $cmd['shortdesc'] : '',
60 'synopsis' => isset( $cmd['synopsis'] ) && is_array( $cmd['synopsis'] ) ? $cmd['synopsis'] : array(),
61 'module' => $module->slug(),
62 );
63 }
64 }
65 ksort( $map );
66 return $map;
67 }
68
69 /**
70 * A catalog of the available commands for discovery (the run_command
71 * tool advertises these so the AI knows what it can call).
72 *
73 * @return array<int, array{command:string, description:string, module:string, options:string[]}>
74 */
75 public static function catalog(): array {
76 $out = array();
77 foreach ( self::commands() as $name => $spec ) {
78 $options = array();
79 foreach ( $spec['synopsis'] as $arg ) {
80 if ( isset( $arg['name'] ) ) {
81 $type = $arg['type'] ?? 'assoc';
82 $options[] = ( 'positional' === $type ? '<' . $arg['name'] . '>' : '--' . $arg['name'] );
83 }
84 }
85 $out[] = array(
86 'command' => $name,
87 'description' => $spec['shortdesc'],
88 'module' => $spec['module'],
89 'options' => $options,
90 );
91 }
92 return $out;
93 }
94
95 /**
96 * Run a command by name.
97 *
98 * @param string $command Full command name, with or without the
99 * leading "xspeed " (e.g. "cloudflare purge"
100 * or "xspeed cloudflare purge").
101 * @param array $args Positional args.
102 * @param array $assoc Named options / flags (e.g. ['url' => '…']).
103 * @return array{command:string, ok:bool, output:string, lines:string[], error?:string}|\WP_Error
104 */
105 public static function run( string $command, array $args = array(), array $assoc = array() ) {
106 $input = self::normalize( $command );
107 $commands = self::commands();
108
109 // A registered command name may be a prefix (e.g. "xspeed db") with
110 // the subcommand passed as a positional arg ("scan"). Resolve to the
111 // LONGEST registered name that prefixes the input, and fold any
112 // trailing words into the leading positional args.
113 list( $name, $extra ) = self::resolve( $input, $commands );
114
115 if ( '' === $name ) {
116 return new \WP_Error(
117 'xspeed_mcp_unknown_command',
118 sprintf(
119 /* translators: %s: command name. */
120 __( 'Unknown command: %s. Call list_commands to see what is available.', 'xspeed' ),
121 $input
122 ),
123 array( 'status' => 404 )
124 );
125 }
126
127 // Trailing words from the command string come before explicit args.
128 $args = array_merge( $extra, array_values( $args ) );
129
130 $buffer = new Cli_Output_Buffer();
131 Cli_Shim::bind( $buffer );
132
133 $ok = true;
134 $error = '';
135 try {
136 call_user_func( $commands[ $name ]['callback'], $args, $assoc );
137 } catch ( Cli_Error_Signal $e ) {
138 // \WP_CLI::error() was called — a controlled failure, not a fatal.
139 $ok = false;
140 $error = $e->getMessage();
141 } catch ( \Throwable $e ) {
142 $ok = false;
143 $error = $e->getMessage();
144 } finally {
145 Cli_Shim::unbind();
146 }
147
148 $result = array(
149 'command' => $name,
150 'ok' => $ok,
151 'output' => $buffer->text(),
152 'lines' => $buffer->lines(),
153 );
154 if ( '' !== $error ) {
155 $result['error'] = $error;
156 }
157 return $result;
158 }
159
160 /**
161 * Normalize a command name: trim, collapse whitespace, and ensure the
162 * "xspeed " namespace prefix so callers can pass either form.
163 */
164 private static function normalize( string $command ): string {
165 $command = trim( preg_replace( '/\s+/', ' ', $command ) ?? '' );
166 if ( '' === $command ) {
167 return '';
168 }
169 if ( 0 !== strpos( $command, 'xspeed ' ) && 'xspeed' !== $command ) {
170 $command = 'xspeed ' . $command;
171 }
172 return $command;
173 }
174
175 /**
176 * Resolve a normalized input string to the longest registered command
177 * name that prefixes it, returning [name, trailing-words-as-args].
178 * Trailing words become leading positional args (e.g. "xspeed db scan"
179 * → name "xspeed db", args ["scan"]).
180 *
181 * @param string $input Normalized command string.
182 * @param array $commands Command map.
183 * @return array{0:string,1:string[]} [name (''=unresolved), extra args]
184 */
185 private static function resolve( string $input, array $commands ): array {
186 // Exact match wins immediately.
187 if ( isset( $commands[ $input ] ) ) {
188 return array( $input, array() );
189 }
190 $parts = explode( ' ', $input );
191 // Try progressively shorter prefixes; longest match first.
192 for ( $i = count( $parts ); $i >= 1; $i-- ) {
193 $candidate = implode( ' ', array_slice( $parts, 0, $i ) );
194 if ( isset( $commands[ $candidate ] ) ) {
195 return array( $candidate, array_slice( $parts, $i ) );
196 }
197 }
198 return array( '', array() );
199 }
200 }
201