PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.4
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.2.4, at includes/modules/Mcp/Cli_Bridge.php

269 lines 9.2 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 // Optional AI-facing description. `shortdesc` is CLI help
61 // text, written for someone who has ALREADY decided to run
62 // the command — "Show GZIP status (server type, active,
63 // mode)". A model reading tools/list has not decided yet
64 // and needs the opposite: when to reach for this, and what
65 // question it answers. Same string cannot serve both, and
66 // rewriting shortdesc would degrade `--help`. (#184)
67 'ai_hint' => isset( $cmd['ai_hint'] ) ? (string) $cmd['ai_hint'] : '',
68 'synopsis' => isset( $cmd['synopsis'] ) && is_array( $cmd['synopsis'] ) ? $cmd['synopsis'] : array(),
69 'module' => $module->slug(),
70 );
71 }
72 }
73 ksort( $map );
74 return $map;
75 }
76
77 /**
78 * A catalog of the available commands for discovery (the run_command
79 * tool advertises these so the AI knows what it can call).
80 *
81 * @return array<int, array{command:string, description:string, module:string, options:string[]}>
82 */
83 public static function catalog(): array {
84 $out = array();
85 foreach ( self::commands() as $name => $spec ) {
86 $options = array();
87 foreach ( $spec['synopsis'] as $arg ) {
88 if ( isset( $arg['name'] ) ) {
89 $type = $arg['type'] ?? 'assoc';
90 $options[] = ( 'positional' === $type ? '<' . $arg['name'] . '>' : '--' . $arg['name'] );
91 }
92 }
93 $out[] = array(
94 'command' => $name,
95 'description' => $spec['shortdesc'],
96 'module' => $spec['module'],
97 'options' => $options,
98 );
99 }
100 return $out;
101 }
102
103 /**
104 * Run a command by name.
105 *
106 * @param string $command Full command name, with or without the
107 * leading "xspeed " (e.g. "cloudflare purge"
108 * or "xspeed cloudflare purge").
109 * @param array $args Positional args.
110 * @param array $assoc Named options / flags (e.g. ['url' => '…']).
111 * @return array{command:string, ok:bool, output:string, lines:string[], error?:string}|\WP_Error
112 */
113 public static function run( string $command, array $args = array(), array $assoc = array() ) {
114 $input = self::normalize( $command );
115 $commands = self::commands();
116
117 // A registered command name may be a prefix (e.g. "xspeed db") with
118 // the subcommand passed as a positional arg ("scan"). Resolve to the
119 // LONGEST registered name that prefixes the input, and fold any
120 // trailing words into the leading positional args.
121 list( $name, $extra ) = self::resolve( $input, $commands );
122
123 if ( '' === $name ) {
124 return new \WP_Error(
125 'xspeed_mcp_unknown_command',
126 sprintf(
127 /* translators: %s: command name. */
128 __( 'Unknown command: %s. Call list_commands to see what is available.', 'xspeed' ),
129 $input
130 ),
131 array( 'status' => 404 )
132 );
133 }
134
135 // Trailing words from the command string come before explicit args.
136 $args = array_merge( $extra, array_values( $args ) );
137
138 $buffer = new Cli_Output_Buffer();
139 Cli_Shim::bind( $buffer );
140
141 $ok = true;
142 $error = '';
143 try {
144 call_user_func( $commands[ $name ]['callback'], $args, $assoc );
145 } catch ( Cli_Error_Signal $e ) {
146 // \WP_CLI::error() was called — a controlled failure, not a fatal.
147 $ok = false;
148 $error = $e->getMessage();
149 } catch ( \Throwable $e ) {
150 $ok = false;
151 $error = $e->getMessage();
152 } finally {
153 Cli_Shim::unbind();
154 }
155
156 $result = array(
157 'command' => $name,
158 'ok' => $ok,
159 'output' => $buffer->text(),
160 'lines' => $buffer->lines(),
161 );
162 if ( '' !== $error ) {
163 $result['error'] = $error;
164 }
165 return $result;
166 }
167
168 /**
169 * Resolve any (command, args) pair to the canonical command name plus
170 * its leading action, using the SAME resolution `run()` performs.
171 *
172 * Callers reach one action by many spellings — `("db", ["clean"])`,
173 * `("database clean")`, `("xspeed db", ["clean", "--types=x"])` — and a
174 * guard that compares raw strings only stops the spelling it was written
175 * against. Anything deciding whether a call is destructive must classify
176 * it here, not parse the caller's input itself.
177 *
178 * @param string $command Raw command string, any accepted spelling.
179 * @param string[] $args Positional args, if any.
180 * @return array{name:string,action:string} name is '' when unresolved.
181 */
182 public static function classify( string $command, array $args = array() ): array {
183 $input = self::normalize( $command );
184 if ( '' === $input ) {
185 return array(
186 'name' => '',
187 'action' => '',
188 );
189 }
190
191 list( $name, $extra ) = self::resolve( $input, self::commands() );
192
193 /*
194 * Fall back to a structural split when the registry cannot resolve the
195 * input — an unbooted module, a command that is not registered on this
196 * install, or a bare unit-test context all leave commands() empty.
197 *
198 * Callers that ask "is this destructive?" must never be told "no"
199 * merely because the registry was unavailable: that turns a missing
200 * module into a disarmed confirmation. Splitting "xspeed db clean"
201 * into name "xspeed db" + action "clean" costs nothing when the
202 * command does not exist (run() rejects it as unknown a moment later)
203 * and keeps the guard closed when it does.
204 */
205 if ( '' === $name ) {
206 $parts = explode( ' ', $input );
207 $name = implode( ' ', array_slice( $parts, 0, 2 ) );
208 $extra = array_slice( $parts, 2 );
209 }
210
211 // Trailing words from the command string come before explicit args —
212 // identical to run(), so the action seen here is the action that runs.
213 $merged = array_merge( $extra, array_values( $args ) );
214 $action = '';
215 foreach ( $merged as $candidate ) {
216 if ( is_scalar( $candidate ) && '' !== trim( (string) $candidate ) ) {
217 $action = strtolower( trim( (string) $candidate ) );
218 break;
219 }
220 }
221
222 return array(
223 'name' => $name,
224 'action' => $action,
225 );
226 }
227
228 /**
229 * Normalize a command name: trim, collapse whitespace, and ensure the
230 * "xspeed " namespace prefix so callers can pass either form.
231 */
232 private static function normalize( string $command ): string {
233 $command = trim( preg_replace( '/\s+/', ' ', $command ) ?? '' );
234 if ( '' === $command ) {
235 return '';
236 }
237 if ( 0 !== strpos( $command, 'xspeed ' ) && 'xspeed' !== $command ) {
238 $command = 'xspeed ' . $command;
239 }
240 return $command;
241 }
242
243 /**
244 * Resolve a normalized input string to the longest registered command
245 * name that prefixes it, returning [name, trailing-words-as-args].
246 * Trailing words become leading positional args (e.g. "xspeed db scan"
247 * → name "xspeed db", args ["scan"]).
248 *
249 * @param string $input Normalized command string.
250 * @param array $commands Command map.
251 * @return array{0:string,1:string[]} [name (''=unresolved), extra args]
252 */
253 private static function resolve( string $input, array $commands ): array {
254 // Exact match wins immediately.
255 if ( isset( $commands[ $input ] ) ) {
256 return array( $input, array() );
257 }
258 $parts = explode( ' ', $input );
259 // Try progressively shorter prefixes; longest match first.
260 for ( $i = count( $parts ); $i >= 1; $i-- ) {
261 $candidate = implode( ' ', array_slice( $parts, 0, $i ) );
262 if ( isset( $commands[ $candidate ] ) ) {
263 return array( $candidate, array_slice( $parts, $i ) );
264 }
265 }
266 return array( '', array() );
267 }
268 }
269