PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.14
ZIP AI – AI Website Builder & AI Agent v0.0.14
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / core / run-wp-cli.php

run-wp-cli.php in ZIP AI – AI Website Builder & AI Agent 0.0.14, at inc/abilities/core/run-wp-cli.php

3,498 lines 128.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Run WP-CLI Command Ability.
4 *
5 * Dispatch strategy:
6 * 1. The code runs inside a WP-CLI process (`defined( 'WP_CLI' )`).
7 * It delegates to `\WP_CLI::runcommand()`. This gives full WP-CLI
8 * support in-process.
9 * 2. The code runs in a web, REST or AJAX context.
10 * It dispatches the command to a native WordPress PHP function.
11 * The allowlist below selects the function. It uses no subprocess,
12 * no shell, no `proc_open` and no `eval`.
13 *
14 * The native dispatcher covers the WP-CLI commands an AI agent uses to
15 * manage a site (plugin, theme, option, post, user, menu, cron, cache,
16 * transient, rewrite, core, language). A command outside the allowlist
17 * returns a clear error.
18 *
19 * ## Authentication and authorisation chain
20 *
21 * A caller must pass every layer below to reach `execute()`:
22 *
23 * 1. The MCP REST endpoint `/zip-ai/v1/mcp` gate
24 * (`REST_API::check_permission`). It accepts a valid Bearer token.
25 * It matches the token with `hash_equals` against the decrypted
26 * stored auth token. It also accepts a logged-in admin
27 * (`current_user_can( 'manage_options' )`).
28 * 2. `setup_user_context()` resolves `X-WP-User-ID` into a WP user on
29 * the Bearer path. So `current_user_can()` inside the ability
30 * shows the real capabilities of the acting user.
31 * 3. The Abilities API permission_callback
32 * (`Abstract_Ability::check_permission`) checks
33 * `current_user_can( manage_options )` again. It runs this before it
34 * routes to `handle_execute()`.
35 * 4. This class's `execute()` adds more checks. It runs a second
36 * `current_user_can()` check. It runs an explicit `is_super_admin()`
37 * check on multisite. It runs `verify_command_security()` before
38 * dispatch.
39 *
40 * `Abstract_Ability::handle_execute` records every successful call. It
41 * writes to the `zip_ai_audit_log` option via `Event_Logger`. The record
42 * holds the user id, the tool id, the sanitised input, the sanitised
43 * output and a timestamp.
44 *
45 * Compliance: this file contains no forbidden functions from the
46 * WordPress.org Plugin Guidelines.
47 *
48 * @since 0.0.1
49 * @package zip-ai
50 */
51
52 namespace ZipAI\MCP\Classes\Abilities\Core;
53
54 defined( 'ABSPATH' ) || exit;
55
56 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
57 use ZipAI\MCP\Classes\Core\Tool_Types;
58 use ZipAI\MCP\Classes\Core\Response;
59 use ZipAI\MCP\Classes\Core\Utils;
60
61 /**
62 * Ability: execute a WP-CLI-style command via native WP functions.
63 */
64 class RunWpCli extends Abstract_Ability {
65
66 use Security_Verifier_Trait;
67 use Command_Parser_Trait;
68 use Search_Replace_Engine_Trait;
69
70 /** Max bytes of output to return. */
71 const MAX_OUTPUT_BYTES = 2097152;
72
73 /**
74 * Marks the tool as destructive for UI approval flows.
75 *
76 * @var bool
77 */
78 protected $is_destructive = true;
79
80 /**
81 * The agent runs bulk operations through this ability one item at a time —
82 * a large delete or update is hundreds of calls in a minute — so the
83 * interactive default (100/min) throttles a legitimate bulk run partway
84 * through. The REST proxy carries the same limit for the same reason.
85 *
86 * @var int
87 */
88 protected $rate_limit = 1000;
89
90 /**
91 * Configure the ability metadata.
92 *
93 * @return void
94 */
95 public function configure() {
96 $this->id = 'zipai/run-wp-cli';
97 $this->label = 'Run WP-CLI Command';
98 $this->description = 'Run a WP-CLI-style command. Omit the leading "wp" — pass only the subcommand and flags. '
99 . 'When the plugin is running inside a WP-CLI process the full WP-CLI command surface is available; in web/REST context only the allowlisted commands below are dispatched to native WordPress functions.'
100 . "\n\n"
101 . 'READ COMMANDS:'
102 . "\n" . ' plugin list [--status=active|inactive|all] [--format=json]'
103 . "\n" . ' plugin get <slug> [--format=json]'
104 . "\n" . ' plugin is-active <slug>'
105 . "\n" . ' theme list [--status=active|inactive] [--format=json]'
106 . "\n" . ' theme get [<slug>] [--format=json]'
107 . "\n" . ' option get <key>'
108 . "\n" . ' option list [--search=pattern]'
109 . "\n" . ' post list [--post_type=…] [--post_status=…] [--per_page=…] [--page=…] [--fields=ID,post_title,…] [--format=json|count]'
110 . "\n" . ' — paginated: returns `data.pagination` {total,total_pages,page,per_page,has_more}. Use --format=count for the total only; page through with --page when has_more is true.'
111 . "\n" . ' post get <ID> [--fields=…] [--format=json]'
112 . "\n" . ' post meta get <ID> <meta_key>'
113 . "\n" . ' user list [--role=…] [--per_page=…] [--page=…] [--fields=ID,user_login,user_email,…] [--format=json|count]'
114 . "\n" . ' user get <id-or-login> [--format=json]'
115 . "\n" . ' user meta get <id> <meta_key>'
116 . "\n" . ' menu list [--format=json]'
117 . "\n" . ' menu item list <menu> [--format=json]'
118 . "\n" . ' sidebar list [--format=json]'
119 . "\n" . ' widget list <sidebar-id> [--format=json]'
120 . "\n" . ' core version'
121 . "\n" . ' core check-update — pending core updates (from WP\'s cached update data; no live wp.org call)'
122 . "\n" . ' cli info — PHP / WordPress runtime facts for the request (no WP-CLI binary in web context)'
123 . "\n" . ' language core list'
124 . "\n" . ' cron event list [--format=json]'
125 . "\n" . ' transient get <key>'
126 . "\n" . ' transient list [--search=…] [--limit=…] [--network] — list transient keys + expiry (no values)'
127 . "\n" . ' transient type — storage backend (database vs object-cache)'
128 . "\n" . ' term list <taxonomy> [--per_page=…] [--page=…] [--hide_empty=true|false] [--format=json|count]'
129 . "\n" . ' post meta list <ID> — all meta keys for a post'
130 . "\n" . ' user meta list <ID> — all meta keys for a user'
131 . "\n" . ' role list — registered user roles + cap counts'
132 . "\n" . ' role list-caps <role> — capability set for one role'
133 . "\n" . ' db size [--tables] [--human-readable] — database size summary'
134 . "\n" . ' rewrite list — current permalink rewrite rules'
135 . "\n" . ' cron schedule list — registered cron intervals'
136 . "\n" . ' env — WP / PHP / MySQL / extensions diagnostic'
137 . "\n" . ' option list [--search=…] [--autoload=on|off] [--limit=…] — list options, optionally filtered by autoload'
138 . "\n" . ' post-type list [--public=true|false] [--show_in_rest=true|false] [--format=json]'
139 . "\n" . ' taxonomy list [--public=true|false] [--object_type=<post_type>] [--format=json]'
140 . "\n" . ' comment list [--status=approve|hold|spam|trash|all] [--post_id=<ID>] [--search=…] [--per_page=…] [--page=…] [--fields=…] [--format=json|count|ids]'
141 . "\n" . ' — paginated like post list. Default --status=all covers approved + pending; spam/trash need an explicit --status.'
142 . "\n" . ' comment get <ID> [--fields=…]'
143 . "\n" . ' comment count [<post-ID>] — totals by status (approved, moderated, spam, trash)'
144 . "\n" . ' comment status <ID>'
145 . "\n" . ' comment exists <ID>'
146 . "\n" . ' comment meta get <ID> <meta_key>'
147 . "\n" . ' comment meta list <ID>'
148 . "\n\n"
149 . 'WRITE COMMANDS (require user approval):'
150 . "\n" . ' theme activate <slug> — server-side switch_theme()'
151 . "\n" . ' (plugin install/activate/deactivate/delete/update and theme install/delete/update'
152 . "\n" . ' are NOT routed through `run-wp-cli` — use the dedicated'
153 . "\n" . ' abilities zipai/install-plugin, zipai/activate-plugin,'
154 . "\n" . ' zipai/deactivate-plugin, zipai/delete-plugin, zipai/update-plugin,'
155 . "\n" . ' zipai/install-theme, zipai/delete-theme, zipai/update-theme.'
156 . "\n" . ' Calling them via run-wp-cli returns an error.)'
157 . "\n" . ' option update <key> <value> [--format=json]'
158 . "\n" . ' — set a WP option. Idempotent (creates when missing).'
159 . "\n" . ' Brick-risk keys (siteurl, home, admin_email, default_role,'
160 . "\n" . ' db_version, blog_charset, users_can_register) refuse upfront.'
161 . "\n" . ' option delete <key> — remove a WP option (idempotent on missing).'
162 . "\n" . ' post create --post_type=page --post_status=publish --post_title="Title"'
163 . "\n" . ' post update <ID> --post_title="New Title" (post_content edits are blocked here — use editor__apply_change with the page open in the block editor)'
164 . "\n" . ' post delete <ID> [<ID2> …] [--force] — accepts multiple IDs in one call.'
165 . "\n" . ' comment create --comment_post_ID=<ID> --comment_content="…" [--comment_author=…] [--comment_author_email=…] [--comment_approved=0|1]'
166 . "\n" . ' comment update <ID> --comment_content="…" [--comment_author=…]'
167 . "\n" . ' comment delete <ID> [<ID2> …] [--force] — trash by default; --force = permanent. Accepts multiple IDs.'
168 . "\n" . ' comment approve|unapprove|spam|unspam|trash|untrash <ID> [<ID2> …] — moderation; accepts multiple IDs.'
169 . "\n" . ' comment recount <post-ID> [<post-ID2> …] — recalculate cached comment_count on posts'
170 . "\n" . ' menu create <name>'
171 . "\n" . ' menu item add-post <menu-id> <post-id>'
172 . "\n" . ' cache flush'
173 . "\n" . ' rewrite flush'
174 . "\n" . ' transient delete --expired — drop only expired transient pairs (safe cleanup)'
175 . "\n" . ' transient delete --all — drop every transient (destructive cache flush)'
176 . "\n" . ' user add-role <id-or-login> <role> — non-admin roles only (administrator/super-admin blocked)'
177 . "\n" . ' user remove-role <id-or-login> <role> — same restriction'
178 . "\n" . ' user set-role <id-or-login> <role> — replaces all roles; same restriction'
179 . "\n" . ' user add-cap <id-or-login> <cap> — admin-class caps (manage_options, etc.) blocked'
180 . "\n" . ' user remove-cap <id-or-login> <cap> — same restriction'
181 . "\n\n"
182 . 'SITE-WIDE OPERATIONS (require user approval — destructive):'
183 . "\n" . ' search-replace "<old>" "<new>" [--all-tables] [--skip-columns=col1,col2] [--dry-run]'
184 . "\n" . ' Serialized-PHP-aware text replacement across post_content, postmeta, options, comments, etc. '
185 . "\n" . ' `guid` and `user_pass` columns are ALWAYS skipped. Use --dry-run first to preview counts.'
186 . "\n\n"
187 . 'NOTES:'
188 . "\n" . ' • In web/REST context, commands outside the allowlist return an explanatory error. Use the REST / Abilities API for uncovered operations.'
189 . "\n" . ' • "post update --post_content=…" is intentionally blocked: it replaces the entire block-editor content with a plain string, destroying block markup. Use editor__apply_change with the page open in the block editor instead.'
190 . "\n" . ' • Blocked for security: eval, eval-file, shell, package, server, db query, db import, db drop, option add/patch (use option update — it creates when missing), post/comment meta add/update/delete/patch, transient set/patch, transient delete <key> (specific-key — use --expired or --all instead), user create/update/delete, and user meta writes. Brick-risk option keys (siteurl, home, admin_email, default_role, db_version, blog_charset, users_can_register) refuse on option update/delete.';
191 $this->capability = 'manage_options';
192 }
193
194 /**
195 * Report the tool type to the MCP registry.
196 *
197 * @return string
198 */
199 public function get_tool_type() {
200 return Tool_Types::ACTION;
201 }
202
203 /**
204 * Return the tool's input schema.
205 *
206 * @return array<string,mixed>
207 */
208 public function get_input_schema() {
209 return array(
210 'type' => 'object',
211 'properties' => array(
212 'command' => array(
213 'type' => 'string',
214 'description' => 'WP-CLI subcommand and flags — without the leading "wp". '
215 . 'Examples: "option get siteurl", "plugin list --status=active --format=json", '
216 . '"post list --post_type=attachment --format=json --fields=ID,post_title,guid"',
217 ),
218 ),
219 'required' => array( 'command' ),
220 );
221 }
222
223 // ─── Entrypoint ─────────────────────────────────────────────────────────────
224
225 /**
226 * Execute the command.
227 *
228 * @param array<string,mixed> $input Validated input.
229 * @return array<string,mixed> Response data.
230 */
231 public function execute( $input ) {
232 // Defense-in-depth — four checks before dispatch. The Abilities API
233 // permission_callback (`Abstract_Ability::check_permission`) already
234 // gates this execute callback with `current_user_can($capability)`
235 // before it is invoked; we re-run the check here so any future
236 // direct caller is still subject to the same rule.
237 if ( ! is_user_logged_in() ) {
238 return Response::error( 'Authentication required.' );
239 }
240 if ( ! current_user_can( $this->capability ) ) {
241 return Response::error( 'Permission denied. You must have manage_options capability.' );
242 }
243 if ( is_multisite() && ! is_super_admin() ) {
244 return Response::error( 'Permission denied. You must be a network administrator on multisite installations to run WP-CLI commands.' );
245 }
246
247 $command = trim( Utils::to_str( $input['command'] ?? '' ) );
248 if ( '' === $command ) {
249 return Response::error( 'Empty command. Example: "option get siteurl"' );
250 }
251
252 // Cap command length defensively — anything larger is not a real CLI
253 // invocation and risks tying up the parser.
254 if ( strlen( $command ) > 4096 ) {
255 return Response::error( 'Command is too long. Break it into multiple invocations.' );
256 }
257
258 // Strip accidental leading "wp ".
259 $command = (string) preg_replace( '/^wp\s+/i', '', $command );
260
261 $args = $this->parse_command_to_args( $command );
262 if ( is_wp_error( $args ) ) {
263 return Response::error( $args->get_error_message() );
264 }
265
266 $security_check = $this->verify_command_security( $args );
267 if ( is_wp_error( $security_check ) ) {
268 return Response::error( $security_check->get_error_message() );
269 }
270
271 // Fast path: the WP-CLI runtime is ready. Delegate to it. But do NOT
272 // delegate `search-replace`.
273 //
274 // The protections for `search-replace` live inside
275 // `handle_search_replace()`. They exclude the users, usermeta, sitemeta
276 // and ms-global tables. They also filter protected-option rows. The
277 // verifier has no equal for these. It cannot know which needle matches
278 // which option before the walk. A raw string passed to WP-CLI would run
279 // an unguarded replacement (DSA-15). So this one family always uses the
280 // native engine.
281 //
282 // The family test reads EVERY candidate split. It does not read only the
283 // `parse_flags` split. Under one parser `--search search-replace <old>
284 // <new>` binds the family name as a flag value. WP-CLI reads the same
285 // name as the command. That would route the exact command this exception
286 // keeps native.
287 //
288 // This exception does NOT claim full cover on both paths.
289 // `verify_command_security()` runs for both executors. So every SECURITY
290 // rule in it applies to both. But the plugin and theme lifecycle
291 // redirects ("use the dedicated `zipai/install-plugin` ability",
292 // `:386-415`) are dispatcher cases. They are not verifier rules. The
293 // verifier lets those commands through whenever `wp_is_file_mod_allowed()`
294 // is true. On this path they would install or delete server-side. That
295 // skips the consent flow the dedicated abilities provide.
296 //
297 // This branch is NOT dead code. `wp mcp serve` reaches it. It uses the
298 // bundled `lib/mcp-adapter`. Its `McpAdapter` inits on `init` under
299 // WP-CLI. It is a stdio JSON-RPC MCP server. It routes `tools/call` to
300 // abilities. This runs in a process where `WP_CLI` is defined.
301 //
302 // `mcp-adapter/execute-ability` refuses anything without
303 // `meta['mcp']['public'] === true`. The ability loader sets it on every
304 // exposed `zipai/*` ability, this one included, while the external MCP
305 // endpoint is switched on (`External_Mcp::is_enabled()`,
306 // `External_Tool_Policy::is_exposed()`). So this path is reachable
307 // whenever that toggle is on. Do not treat it as unreachable.
308 //
309 // The MCP-context flag of `Protected_Options_Filter` is entered by
310 // `Rest_Api::handle_mcp_request`, and by `External_Mcp::enter_protected_context`
311 // for every call on this plugin's adapter server and for a call on any
312 // other adapter server that runs a `zipai/*` ability, this one included.
313 if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( '\WP_CLI' ) ) {
314 $families = array_column( $this->candidate_positionals( $args ), 0 );
315 if ( ! in_array( 'search-replace', $families, true ) ) {
316 return $this->run_via_wpcli_api( $command );
317 }
318 }
319
320 // Web / REST context — map to native WordPress PHP functions.
321 return $this->dispatch_native( $args );
322 }
323
324 // ─── Security ───────────────────────────────────────────────────────────────
325 // `verify_command_security()` lives in Security_Verifier_Trait
326 // (security-verifier-trait.php).
327
328 // ─── WP-CLI runtime passthrough ─────────────────────────────────────────────
329
330 /**
331 * Delegate to `\WP_CLI::runcommand()` when the request is already running
332 * inside a WP-CLI process.
333 *
334 * @param string $command Subcommand string (no leading "wp").
335 * @return array<string,mixed>
336 */
337 private function run_via_wpcli_api( string $command ): array {
338 try {
339 /**
340 * Narrowed type for `$result`.
341 *
342 * @var object{return_code:int,stdout:string,stderr:string} $result
343 */
344 $result = \WP_CLI::runcommand(
345 $command,
346 array(
347 'launch' => false,
348 'return' => 'all',
349 'exit_error' => false,
350 )
351 );
352 } catch ( \Throwable $e ) {
353 // Internal fault in WP-CLI itself (not a command's own non-zero exit —
354 // that's handled below with the real stderr the agent needs). The raw
355 // exception text can leak class names / paths, so keep it server-side
356 // (WP_DEBUG) and return a static message.
357 Utils::debug_log( 'WP-CLI runtime fault', $e->getMessage() );
358 return Response::error( 'WP-CLI could not run the command. Please try again.' );
359 }
360
361 if ( 0 !== $result->return_code ) {
362 $error_raw = '' !== trim( $result->stderr ) ? $result->stderr : $result->stdout;
363 $error = trim( $error_raw );
364 if ( '' === $error ) {
365 $error = sprintf( 'Command failed (exit %d)', $result->return_code );
366 }
367 return Response::error( $error );
368 }
369
370 $stdout = trim( $result->stdout );
371 $is_truncated = false;
372
373 if ( strlen( $stdout ) >= self::MAX_OUTPUT_BYTES ) {
374 $stdout = substr( $stdout, 0, self::MAX_OUTPUT_BYTES );
375 $is_truncated = true;
376 }
377
378 /**
379 * Parsed command output.
380 *
381 * @var array<string,mixed> $parsed
382 */
383 $parsed = $this->parse_output( $stdout );
384 if ( $is_truncated ) {
385 $parsed['truncated'] = true;
386 $parsed['truncated_message'] = 'Output truncated. Use --format=json with pagination flags to page through results.';
387 }
388 return $parsed;
389 }
390
391 // ─── Native dispatcher ──────────────────────────────────────────────────────
392
393 /**
394 * Dispatch an allowlisted WP-CLI command to a native WordPress handler.
395 *
396 * @param string[] $args Parsed command tokens (no leading "wp").
397 * @return array<string,mixed>
398 */
399 private function dispatch_native( array $args ): array {
400 list( $positional, $flags ) = $this->parse_flags( $args );
401
402 $base = strtolower( $positional[0] ?? '' );
403 $sub = strtolower( $positional[1] ?? '' );
404
405 // Single-token commands whose first positional is the command itself
406 // (rather than a "<base> <sub>" pair). Branch before the switch so the
407 // switch's "$base $sub" key doesn't need to encode the search/replace
408 // arguments.
409 if ( 'search-replace' === $base ) {
410 return $this->handle_search_replace( array_slice( $positional, 1 ), $flags );
411 }
412
413 // Routes where the subcommand is a single word after the base.
414 $route = trim( "{$base} {$sub}" );
415 $rest = array_slice( $positional, 2 );
416
417 switch ( $route ) {
418 // Plugin.
419 case 'plugin list':
420 return $this->handle_plugin_list( $flags );
421 case 'plugin get':
422 return $this->handle_plugin_get( $rest, $flags );
423 case 'plugin is-active':
424 case 'plugin is_active':
425 return $this->handle_plugin_is_active( $rest );
426
427 // Plugin lifecycle (install/activate/deactivate/delete) is handled by
428 // dedicated browser-proxied abilities. Refuse here with an explicit
429 // pointer so the LLM picks the right tool instead of running
430 // `wp plugin activate ...` and falling through to a stub.
431 case 'plugin install':
432 return Response::error(
433 '`wp plugin install` is not available here. Use the dedicated `zipai/install-plugin` ability — it installs through the user\'s browser session under the user\'s real `install_plugins` capability.'
434 );
435 case 'plugin activate':
436 return Response::error(
437 '`wp plugin activate` is not available here. Use the dedicated `zipai/activate-plugin` ability — it activates through the user\'s browser session under the user\'s real `activate_plugins` capability.'
438 );
439 case 'plugin deactivate':
440 return Response::error(
441 '`wp plugin deactivate` is not available here. Use the dedicated `zipai/deactivate-plugin` ability — it deactivates through the user\'s browser session under the user\'s real `activate_plugins` capability.'
442 );
443 case 'plugin delete':
444 return Response::error(
445 '`wp plugin delete` is not available here. Use the dedicated `zipai/delete-plugin` ability — it deletes through the user\'s browser session under the user\'s real `delete_plugins` capability.'
446 );
447 case 'plugin update':
448 return Response::error(
449 '`wp plugin update` is not available here. Use the dedicated `zipai/update-plugin` ability — it updates server-side under the user\'s real `update_plugins` capability and verifies each version bump.'
450 );
451
452 // Theme.
453 case 'theme list':
454 return $this->handle_theme_list( $flags );
455 case 'theme get':
456 case 'theme status':
457 return $this->handle_theme_get( $rest, $flags );
458 case 'theme activate':
459 return $this->handle_theme_activate( $rest );
460 case 'theme install':
461 return Response::error(
462 '`wp theme install` is not available here. Use the dedicated `zipai/install-theme` ability — it installs through the user\'s browser session under the user\'s real `install_themes` capability.'
463 );
464 case 'theme delete':
465 return Response::error(
466 '`wp theme delete` is not available here. Use the dedicated `zipai/delete-theme` ability — it deletes through the user\'s browser session under the user\'s real `delete_themes` capability.'
467 );
468 case 'theme update':
469 return Response::error(
470 '`wp theme update` is not available here. Use the dedicated `zipai/update-theme` ability — it updates server-side under the user\'s real `update_themes` capability and verifies each version bump.'
471 );
472
473 // Option. Reads + targeted writes (update / delete) are allowed.
474 // `add` / `patch` stay blocked in verify_command_security — `update`
475 // is idempotent and covers the legitimate write surface. Protected
476 // keys (Protected_Options_Filter::write_protected_keys()) refuse upfront.
477 case 'option get':
478 return $this->handle_option_get( $rest, $flags );
479 case 'option list':
480 return $this->handle_option_list( $flags );
481 case 'option update':
482 return $this->handle_option_update( $rest, $flags );
483 case 'option delete':
484 return $this->handle_option_delete( $rest );
485
486 // Transient — `set`/`patch` and arbitrary-key `delete` are blocked in
487 // verify_command_security; the four cases below are the safe ones.
488 case 'transient get':
489 return $this->handle_transient_get( $rest );
490 case 'transient list':
491 return $this->handle_transient_list( $flags );
492 case 'transient type':
493 return $this->handle_transient_type();
494 case 'transient delete':
495 return $this->handle_transient_delete( $flags );
496
497 // Post.
498 case 'post list':
499 return $this->handle_post_list( $flags );
500 case 'post get':
501 return $this->handle_post_get( $rest, $flags );
502 case 'post create':
503 return $this->handle_post_create( $flags );
504 case 'post update':
505 return $this->handle_post_update( $rest, $flags );
506 case 'post delete':
507 return $this->handle_post_delete( $rest, $flags );
508
509 // Post meta — 3 tokens: `post meta <verb> ...`.
510 case 'post meta':
511 return $this->route_meta( 'post', $rest, $flags );
512
513 // Comment.
514 case 'comment list':
515 return $this->handle_comment_list( $flags );
516 case 'comment get':
517 return $this->handle_comment_get( $rest, $flags );
518 case 'comment count':
519 return $this->handle_comment_count( $rest );
520 case 'comment exists':
521 return $this->handle_comment_exists( $rest );
522 case 'comment status':
523 return $this->handle_comment_status( $rest );
524 case 'comment create':
525 return $this->handle_comment_create( $flags );
526 case 'comment update':
527 return $this->handle_comment_update( $rest, $flags );
528 case 'comment delete':
529 return $this->handle_comment_delete( $rest, $flags );
530 case 'comment approve':
531 case 'comment unapprove':
532 case 'comment spam':
533 case 'comment unspam':
534 case 'comment trash':
535 case 'comment untrash':
536 return $this->handle_comment_set_status( $sub, $rest );
537 case 'comment recount':
538 return $this->handle_comment_recount( $rest );
539
540 // Comment meta — reads only (writes blocked in verify_command_security).
541 case 'comment meta':
542 return $this->route_meta( 'comment', $rest, $flags );
543
544 // User.
545 case 'user list':
546 return $this->handle_user_list( $flags );
547 case 'user get':
548 return $this->handle_user_get( $rest, $flags );
549 case 'user meta':
550 return $this->route_meta( 'user', $rest, $flags );
551 case 'user add-role':
552 return $this->handle_user_role_change( 'add', $rest );
553 case 'user remove-role':
554 return $this->handle_user_role_change( 'remove', $rest );
555 case 'user set-role':
556 return $this->handle_user_role_change( 'set', $rest );
557 case 'user add-cap':
558 return $this->handle_user_cap_change( 'add', $rest );
559 case 'user remove-cap':
560 return $this->handle_user_cap_change( 'remove', $rest );
561
562 // Term.
563 case 'term list':
564 return $this->handle_term_list( $rest, $flags );
565
566 // Post type / taxonomy schema discovery.
567 case 'post-type list':
568 case 'post_type list':
569 return $this->handle_post_type_list( $flags );
570 case 'taxonomy list':
571 return $this->handle_taxonomy_list( $flags );
572
573 // Menu.
574 case 'menu list':
575 return $this->handle_menu_list( $flags );
576 case 'menu create':
577 return $this->handle_menu_create( $rest );
578 case 'menu item':
579 return $this->route_menu_item( $rest, $flags );
580
581 // Sidebar / widget.
582 case 'sidebar list':
583 return $this->handle_sidebar_list();
584 case 'widget list':
585 return $this->handle_widget_list( $rest );
586
587 // Core / language / misc.
588 case 'core version':
589 return Response::success( array( 'version' => get_bloginfo( 'version' ) ) );
590 case 'core check-update':
591 case 'core check_update':
592 return $this->handle_core_check_update();
593
594 // Core UPDATES are deliberately unsupported — no route, no ability.
595 // An explicit refusal beats the generic catch-all so the LLM relays
596 // the manual path instead of hunting for a fallback tool.
597 case 'core update':
598 case 'core upgrade':
599 case 'core download':
600 return Response::error(
601 'WordPress core updates are not supported by the agent — there is no core-update ability and `wp core update` has no web-context route. Ask the user to apply the update in wp-admin → Dashboard → Updates.'
602 );
603
604 // CLI runtime probe — reports the PHP/WP process serving the
605 // request (there is no WP-CLI binary in web/REST context).
606 case 'cli info':
607 return $this->handle_cli_info();
608
609 case 'language core':
610 if ( 'list' === ( $positional[2] ?? '' ) ) {
611 return Response::success( get_available_languages() );
612 }
613 return Response::error( sprintf( 'Unsupported subcommand: "language core %s".', $positional[2] ?? '' ) );
614 case 'cache flush':
615 wp_cache_flush();
616 return Response::success( array( 'success' => true ) );
617 case 'rewrite flush':
618 // Soft flush: drop the cached rewrite_rules option so WordPress
619 // regenerates rules on the next request. Equivalent to passing
620 // false to flush_rewrite_rules() but without writing .htaccess
621 // or hitting the VIP restricted-functions sniff.
622 delete_option( 'rewrite_rules' );
623 return Response::success( array( 'success' => true ) );
624 case 'cron event':
625 if ( 'list' === ( $positional[2] ?? '' ) ) {
626 return $this->handle_cron_event_list();
627 }
628 return Response::error( sprintf( 'Unsupported subcommand: "cron event %s".', $positional[2] ?? '' ) );
629 case 'cron schedule':
630 if ( 'list' === ( $positional[2] ?? '' ) ) {
631 return $this->handle_cron_schedule_list();
632 }
633 return Response::error( sprintf( 'Unsupported subcommand: "cron schedule %s".', $positional[2] ?? '' ) );
634 case 'rewrite list':
635 return $this->handle_rewrite_list();
636
637 // Diagnostics: role discovery, db size, env info.
638 case 'role list':
639 return $this->handle_role_list( $flags );
640 case 'role list-caps':
641 return $this->handle_role_list_caps( $rest );
642 case 'db size':
643 return $this->handle_db_size( $flags );
644 case 'env':
645 return $this->handle_env();
646 }
647
648 return Response::error(
649 sprintf(
650 'Command "%s" is not available in web/REST context on the WordPress.org build. Use the REST or Abilities API for uncovered operations.',
651 trim( "{$base} {$sub}" )
652 )
653 );
654 }
655
656 // ─── Plugin handlers ────────────────────────────────────────────────────────
657
658 /**
659 * Handle "plugin list".
660 *
661 * @param array<string,mixed> $flags Parsed flags.
662 * @return array<string,mixed>
663 */
664 private function handle_plugin_list( array $flags ): array {
665 if ( ! function_exists( 'get_plugins' ) ) {
666 require_once ABSPATH . 'wp-admin/includes/plugin.php';
667 }
668 // `get_plugin_updates()` reads the cached `update_plugins` site
669 // transient (populated by core's scheduled `wp_update_plugins()`) —
670 // no forced wp.org call, so it's safe and fast in web/REST context.
671 // Without this the `update` / `update_version` columns the caller asks
672 // for would always come back empty and a Site Health snapshot would
673 // under-report available updates.
674 if ( ! function_exists( 'get_plugin_updates' ) ) {
675 require_once ABSPATH . 'wp-admin/includes/update.php';
676 }
677 $plugin_updates = function_exists( 'get_plugin_updates' ) ? get_plugin_updates() : array();
678
679 $plugins = get_plugins();
680 $status = strtolower( Utils::to_str( $flags['status'] ?? 'all', 'all' ) );
681
682 $out = array();
683 foreach ( $plugins as $file => $data ) {
684 $active = is_plugin_active( $file );
685 if ( 'active' === $status && ! $active ) {
686 continue;
687 }
688 if ( 'inactive' === $status && $active ) {
689 continue;
690 }
691 $has_update = isset( $plugin_updates[ $file ] );
692 $update_version = '';
693 if ( $has_update ) {
694 $entry = $plugin_updates[ $file ];
695 if ( isset( $entry->update ) && is_object( $entry->update ) && isset( $entry->update->new_version ) ) {
696 $update_version = Utils::to_str( $entry->update->new_version );
697 }
698 }
699 $out[] = array(
700 'name' => sanitize_title( Utils::to_str( $data['Name'] ?? basename( dirname( $file ) ) ) ),
701 'file' => $file,
702 'title' => $data['Name'] ?? '',
703 'status' => $active ? 'active' : 'inactive',
704 'version' => $data['Version'] ?? '',
705 // Mirror WP-CLI's `wp plugin list` columns: 'available' | 'none'.
706 'update' => $has_update ? 'available' : 'none',
707 'update_version' => $update_version,
708 'description' => wp_trim_words( wp_strip_all_tags( Utils::to_str( $data['Description'] ?? '' ) ), 30 ),
709 'author' => wp_strip_all_tags( Utils::to_str( $data['Author'] ?? '' ) ),
710 );
711 }
712 return Response::success( $out );
713 }
714
715 /**
716 * Handle "plugin get <slug>".
717 *
718 * @param string[] $positional Remaining positional args.
719 * @param array<string,mixed> $flags Parsed flags.
720 * @return array<string,mixed>
721 */
722 private function handle_plugin_get( array $positional, array $flags ) {
723 unset( $flags );
724 $slug = $positional[0] ?? '';
725 if ( '' === $slug ) {
726 return Response::error( 'Usage: plugin get <slug>' );
727 }
728 if ( ! function_exists( 'get_plugins' ) ) {
729 require_once ABSPATH . 'wp-admin/includes/plugin.php';
730 }
731 $file = $this->resolve_plugin_file( $slug );
732 if ( null === $file ) {
733 return Response::error( sprintf( 'Plugin "%s" not found.', $slug ) );
734 }
735 $plugins = get_plugins();
736 $data = $plugins[ $file ] ?? array();
737 return Response::success(
738 array(
739 'file' => $file,
740 'name' => sanitize_title( Utils::to_str( $data['Name'] ?? $slug ) ),
741 'title' => $data['Name'] ?? '',
742 'status' => is_plugin_active( $file ) ? 'active' : 'inactive',
743 'version' => $data['Version'] ?? '',
744 'description' => wp_strip_all_tags( Utils::to_str( $data['Description'] ?? '' ) ),
745 'author' => wp_strip_all_tags( Utils::to_str( $data['Author'] ?? '' ) ),
746 'requires_wp' => $data['RequiresWP'] ?? '',
747 'requires_php' => $data['RequiresPHP'] ?? '',
748 )
749 );
750 }
751
752 /**
753 * Handle "plugin is-active".
754 *
755 * @param string[] $positional Remaining positional args.
756 * @return array<string,mixed>
757 */
758 private function handle_plugin_is_active( array $positional ): array {
759 $slug = $positional[0] ?? '';
760 if ( '' === $slug ) {
761 return Response::error( 'Usage: plugin is-active <slug>' );
762 }
763 if ( ! function_exists( 'is_plugin_active' ) ) {
764 require_once ABSPATH . 'wp-admin/includes/plugin.php';
765 }
766 $file = $this->resolve_plugin_file( $slug );
767 if ( null === $file ) {
768 return Response::success( array( 'active' => false ) );
769 }
770 return Response::success( array( 'active' => is_plugin_active( $file ) ) );
771 }
772
773 /**
774 * Resolve a plugin slug or folder name to its main plugin file.
775 *
776 * @param string $slug Slug or "folder/file.php".
777 * @return string|null Plugin file relative to plugins/, or null if not found.
778 */
779 private function resolve_plugin_file( string $slug ): ?string {
780 if ( ! function_exists( 'get_plugins' ) ) {
781 require_once ABSPATH . 'wp-admin/includes/plugin.php';
782 }
783 $plugins = get_plugins();
784 if ( isset( $plugins[ $slug ] ) ) {
785 return $slug;
786 }
787 foreach ( array_keys( $plugins ) as $file ) {
788 if ( 0 === strpos( $file, $slug . '/' ) || 0 === strcasecmp( dirname( $file ), $slug ) ) {
789 return $file;
790 }
791 }
792 return null;
793 }
794
795 // ─── Theme handlers ─────────────────────────────────────────────────────────
796
797 /**
798 * Handle "theme list".
799 *
800 * @param array<string,mixed> $flags Parsed flags.
801 * @return array<string,mixed>
802 */
803 private function handle_theme_list( array $flags ): array {
804 $status = strtolower( Utils::to_str( $flags['status'] ?? 'all', 'all' ) );
805 $current = get_option( 'stylesheet' );
806
807 // Cached theme-update data (`update_themes` transient) — same
808 // rationale as plugin updates: read-only, no forced wp.org call,
809 // keeps the `update` / `update_version` columns populated.
810 if ( ! function_exists( 'get_theme_updates' ) ) {
811 require_once ABSPATH . 'wp-admin/includes/update.php';
812 }
813 $theme_updates = function_exists( 'get_theme_updates' ) ? get_theme_updates() : array();
814
815 $out = array();
816 foreach ( wp_get_themes() as $stylesheet => $theme ) {
817 $is_active = ( $stylesheet === $current );
818 if ( 'active' === $status && ! $is_active ) {
819 continue;
820 }
821 if ( 'inactive' === $status && $is_active ) {
822 continue;
823 }
824 // `get_theme_updates()` returns WP_Theme objects with an `update`
825 // array property (keyed by stylesheet) when an update is offered.
826 $has_update = isset( $theme_updates[ $stylesheet ] );
827 $update_version = '';
828 if ( $has_update ) {
829 $entry = $theme_updates[ $stylesheet ];
830 // get_theme_updates() sets ->update to the update-data array; the
831 // WP_Theme stub types that dynamic property as bool, so capture it
832 // before checking.
833 /**
834 * Narrowed type for `$update_data`.
835 *
836 * @var array<string,mixed>|bool $update_data
837 */
838 $update_data = $entry->update;
839 if ( is_array( $update_data ) && isset( $update_data['new_version'] ) ) {
840 $update_version = Utils::to_str( $update_data['new_version'] );
841 }
842 }
843 $out[] = array(
844 'name' => $stylesheet,
845 'title' => $theme->get( 'Name' ),
846 'status' => $is_active ? 'active' : 'inactive',
847 'version' => $theme->get( 'Version' ),
848 'update' => $has_update ? 'available' : 'none',
849 'update_version' => $update_version,
850 'author' => wp_strip_all_tags( (string) $theme->get( 'Author' ) ),
851 );
852 }
853 return Response::success( $out );
854 }
855
856 /**
857 * Handle "theme get".
858 *
859 * @param string[] $positional Remaining positional args.
860 * @param array<string,mixed> $flags Parsed flags.
861 * @return array<string,mixed>
862 */
863 private function handle_theme_get( array $positional, array $flags ) {
864 unset( $flags );
865 $slug = $positional[0] ?? '';
866 $theme = '' === $slug ? wp_get_theme() : wp_get_theme( $slug );
867 if ( ! $theme->exists() ) {
868 return Response::error( sprintf( 'Theme "%s" not found.', $slug ) );
869 }
870 return Response::success(
871 array(
872 'name' => $theme->get_stylesheet(),
873 'title' => $theme->get( 'Name' ),
874 'status' => ( $theme->get_stylesheet() === get_option( 'stylesheet' ) ) ? 'active' : 'inactive',
875 'version' => $theme->get( 'Version' ),
876 'author' => wp_strip_all_tags( (string) $theme->get( 'Author' ) ),
877 'description' => wp_strip_all_tags( (string) $theme->get( 'Description' ) ),
878 'parent' => $theme->parent() ? $theme->parent()->get_stylesheet() : '',
879 'template' => $theme->get_template(),
880 )
881 );
882 }
883
884 /**
885 * Handle "theme activate".
886 *
887 * @param string[] $positional Remaining positional args.
888 * @return array<string,mixed>
889 */
890 private function handle_theme_activate( array $positional ): array {
891 $slug = $positional[0] ?? '';
892 if ( '' === $slug ) {
893 return Response::error( 'Usage: theme activate <slug>' );
894 }
895 $theme = wp_get_theme( $slug );
896 if ( ! $theme->exists() ) {
897 return Response::error( sprintf( 'Theme "%s" not found.', $slug ) );
898 }
899 switch_theme( $slug );
900 return Response::success(
901 array(
902 'activated' => $slug,
903 )
904 );
905 }
906
907 // ─── Option handlers ────────────────────────────────────────────────────────
908
909 /**
910 * Handle "option get".
911 *
912 * @param string[] $positional Remaining positional args.
913 * @param array<string,mixed> $flags Parsed flags.
914 * @return array<string,mixed>
915 */
916 private function handle_option_get( array $positional, array $flags ) {
917 unset( $flags );
918 if ( empty( $positional ) ) {
919 return Response::error( 'Usage: option get <key> [<key2> …]' );
920 }
921 $out = array();
922 foreach ( $positional as $key ) {
923 $out[ $key ] = get_option( $key );
924 }
925 return Response::success( 1 === count( $out ) ? reset( $out ) : $out );
926 }
927
928 /**
929 * Handle "option list".
930 *
931 * @param array<string,mixed> $flags Parsed flags.
932 * @return array<string,mixed>
933 */
934 private function handle_option_list( array $flags ): array {
935 global $wpdb;
936 /**
937 * Narrowed type for `$wpdb`.
938 *
939 * @var \wpdb $wpdb
940 */
941 $search = Utils::to_str( $flags['search'] ?? '' );
942 $limit = Utils::to_int( $flags['limit'] ?? 200, 200 );
943 $limit = max( 1, min( $limit, 1000 ) );
944
945 // --autoload=on|off|yes|no — useful for performance audits ("what's
946 // loaded on every request?"). Maps to the `yes`/`no` values stored in
947 // the wp_options.autoload column.
948 $autoload_filter = '';
949 if ( isset( $flags['autoload'] ) ) {
950 $raw = strtolower( Utils::to_str( $flags['autoload'] ) );
951 if ( in_array( $raw, array( 'on', 'yes', 'true', '1' ), true ) ) {
952 $autoload_filter = 'yes';
953 } elseif ( in_array( $raw, array( 'off', 'no', 'false', '0' ), true ) ) {
954 $autoload_filter = 'no';
955 }
956 }
957
958 $sql = 'SELECT option_name, autoload FROM %i';
959 $bindings = array( $wpdb->options );
960 if ( '' !== $search && '' !== $autoload_filter ) {
961 $sql .= ' WHERE option_name LIKE %s AND autoload = %s';
962 $bindings[] = '%' . $wpdb->esc_like( $search ) . '%';
963 $bindings[] = $autoload_filter;
964 } elseif ( '' !== $search ) {
965 $sql .= ' WHERE option_name LIKE %s';
966 $bindings[] = '%' . $wpdb->esc_like( $search ) . '%';
967 } elseif ( '' !== $autoload_filter ) {
968 $sql .= ' WHERE autoload = %s';
969 $bindings[] = $autoload_filter;
970 }
971 $sql .= ' ORDER BY option_name ASC LIMIT %d';
972 $bindings[] = $limit;
973 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%d placeholders and run through $wpdb->prepare().
974 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $bindings ), ARRAY_A );
975
976 return Response::success( is_array( $rows ) ? $rows : array() );
977 }
978
979 /**
980 * Handle "option update <key> <value>".
981 *
982 * Mirrors WP-CLI's `wp option update <key> <value> [--format=json]`:
983 * - Idempotent: it creates the option when the option is missing.
984 * - `--format=json`: it parses `<value>` as JSON. So callers can pass
985 * arrays or objects. They do not serialize by hand.
986 *
987 * The code refuses keys in
988 * `Protected_Options_Filter::write_protected_keys()` upfront with a clear
989 * error. These are brick-risk options. They also include keys this generic
990 * primitive must not reach (`active_plugins`, `template`, `stylesheet`,
991 * `upload_path`, `upload_url_path`, `{prefix}user_roles`).
992 * `Protected_Options_Filter` also intercepts the `update_option()` call.
993 * This is a second backstop for the filter-installed subset. The upfront
994 * check means the code never claims success on a write that the filter
995 * turned into a no-op. That failure mode broke theme-activate before the
996 * protect-list fix.
997 *
998 * @param string[] $positional Remaining positional args ([key, value]).
999 * @param array<string,mixed> $flags Parsed flags (`--format=json` recognised).
1000 * @return array<string,mixed>
1001 */
1002 private function handle_option_update( array $positional, array $flags ): array {
1003 $key = isset( $positional[0] ) ? (string) $positional[0] : '';
1004 if ( '' === $key ) {
1005 return Response::error( 'Usage: option update <key> <value> [--format=json]' );
1006 }
1007
1008 if ( \ZipAI\MCP\Classes\Security\Protected_Options_Filter::is_write_protected( $key ) ) {
1009 return Response::error(
1010 sprintf(
1011 'Security policy: option "%s" is protected and cannot be modified via the AI agent. Edit manually in wp-admin → Settings, or use the dedicated ability for it (theme activate / plugin activate).',
1012 $key
1013 )
1014 );
1015 }
1016
1017 // Value: positional[1] is the raw string. `--format=json` reinterprets
1018 // it as a JSON literal (lets callers store arrays/objects).
1019 if ( ! array_key_exists( 1, $positional ) ) {
1020 return Response::error( 'Usage: option update <key> <value> [--format=json]' );
1021 }
1022 $value = $positional[1];
1023 if ( isset( $flags['format'] ) && 'json' === strtolower( Utils::to_str( $flags['format'] ) ) ) {
1024 $decoded = json_decode( $value, true );
1025 if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
1026 return Response::error( sprintf( 'Invalid JSON value: %s.', json_last_error_msg() ) );
1027 }
1028 $value = $decoded;
1029 }
1030
1031 $ok = update_option( $key, $value );
1032
1033 // `update_option()` returns false for BOTH "value already at this
1034 // state" and "filter blocked the write" — indistinguishable from
1035 // the return value alone. Read back and compare to catch the
1036 // latter case: if the post-write value still differs from what we
1037 // tried to write, a `pre_update_option_<key>` filter (security
1038 // plugin / theme / other code) refused it.
1039 $post = get_option( $key );
1040 if ( ! $ok && \maybe_serialize( $post ) !== \maybe_serialize( $value ) ) {
1041 return Response::error(
1042 sprintf(
1043 'Option "%s" did not update — a `pre_update_option_%s` filter (security plugin, theme, or other code) is blocking the write.',
1044 $key,
1045 $key
1046 )
1047 );
1048 }
1049
1050 return Response::success(
1051 array(
1052 'key' => $key,
1053 'updated' => (bool) $ok,
1054 'message' => $ok
1055 ? sprintf( 'Option "%s" updated.', $key )
1056 : sprintf( 'Option "%s" already at this value; no write.', $key ),
1057 )
1058 );
1059 }
1060
1061 /**
1062 * Handle "option delete <key>".
1063 *
1064 * Idempotent on missing options (returns success with `deleted: false`).
1065 * Brick-risk keys refuse upfront — see handle_option_update for rationale.
1066 *
1067 * @param string[] $positional Remaining positional args ([key]).
1068 * @return array<string,mixed>
1069 */
1070 private function handle_option_delete( array $positional ): array {
1071 $key = isset( $positional[0] ) ? (string) $positional[0] : '';
1072 if ( '' === $key ) {
1073 return Response::error( 'Usage: option delete <key>' );
1074 }
1075
1076 if ( \ZipAI\MCP\Classes\Security\Protected_Options_Filter::is_write_protected( $key ) ) {
1077 return Response::error(
1078 sprintf(
1079 'Security policy: option "%s" is protected and cannot be deleted via the AI agent.',
1080 $key
1081 )
1082 );
1083 }
1084
1085 // `false` from `get_option` with no default = option doesn't exist.
1086 // Idempotent: treat absent-option as a successful no-op.
1087 if ( false === get_option( $key, false ) ) {
1088 return Response::success(
1089 array(
1090 'key' => $key,
1091 'deleted' => false,
1092 'message' => sprintf( 'Option "%s" did not exist; nothing to delete.', $key ),
1093 )
1094 );
1095 }
1096
1097 $ok = delete_option( $key );
1098 if ( ! $ok ) {
1099 return Response::error( sprintf( 'Failed to delete option "%s".', $key ) );
1100 }
1101
1102 return Response::success(
1103 array(
1104 'key' => $key,
1105 'deleted' => true,
1106 'message' => sprintf( 'Option "%s" deleted.', $key ),
1107 )
1108 );
1109 }
1110
1111 // ─── Transient handlers ─────────────────────────────────────────────────────
1112
1113 /**
1114 * Handle "transient get".
1115 *
1116 * @param string[] $positional Remaining positional args.
1117 * @return array<string,mixed>
1118 */
1119 private function handle_transient_get( array $positional ): array {
1120 $key = $positional[0] ?? '';
1121 if ( '' === $key ) {
1122 return Response::error( 'Usage: transient get <key>' );
1123 }
1124 return Response::success( array( 'value' => get_transient( $key ) ) );
1125 }
1126
1127 /**
1128 * Handle "transient list [--search=…] [--limit=…] [--network]".
1129 *
1130 * Lists transient keys directly from `wp_options` (or `wp_sitemeta` when
1131 * `--network` is passed on multisite). Returns only the key name, expiration
1132 * timestamp, and seconds-until-expiry — NOT the value, because transient
1133 * payloads can be large serialised structures that blow the response cap.
1134 * Use `transient get <key>` to fetch a specific value.
1135 *
1136 * @param array<string,mixed> $flags Parsed flags.
1137 * @return array<string,mixed>
1138 */
1139 private function handle_transient_list( array $flags ): array {
1140 global $wpdb;
1141 /**
1142 * Narrowed type for `$wpdb`.
1143 *
1144 * @var \wpdb $wpdb
1145 */
1146 $is_network = ! empty( $flags['network'] );
1147 if ( $is_network && ! is_multisite() ) {
1148 return Response::error( '--network requires a multisite install.' );
1149 }
1150
1151 $search = isset( $flags['search'] ) ? Utils::to_str( $flags['search'] ) : '';
1152 $limit = isset( $flags['limit'] ) ? max( 1, min( 1000, Utils::to_int( $flags['limit'] ) ) ) : 200;
1153
1154 $prefix = $is_network ? '_site_transient_' : '_transient_';
1155 $timeout_prefix = $is_network ? '_site_transient_timeout_' : '_transient_timeout_';
1156
1157 if ( $is_network ) {
1158 $table = (string) $wpdb->sitemeta;
1159 $name_column = 'meta_key';
1160 } else {
1161 $table = (string) $wpdb->options;
1162 $name_column = 'option_name';
1163 }
1164
1165 $sql = 'SELECT %i AS k FROM %i WHERE %i LIKE %s AND %i NOT LIKE %s';
1166 $args = array(
1167 $name_column,
1168 $table,
1169 $name_column,
1170 $wpdb->esc_like( $prefix ) . '%',
1171 $name_column,
1172 $wpdb->esc_like( $timeout_prefix ) . '%',
1173 );
1174 if ( $is_network ) {
1175 $sql .= ' AND site_id = %d';
1176 $args[] = get_current_network_id();
1177 }
1178 if ( '' !== $search ) {
1179 $sql .= ' AND %i LIKE %s';
1180 $args[] = $name_column;
1181 $args[] = '%' . $wpdb->esc_like( $search ) . '%';
1182 }
1183 $sql .= ' ORDER BY %i ASC LIMIT %d';
1184 $args[] = $name_column;
1185 $args[] = $limit;
1186
1187 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%s/%d placeholders and run through $wpdb->prepare().
1188 $rows = $wpdb->get_col( $wpdb->prepare( $sql, $args ) );
1189
1190 $now = time();
1191 $result = array();
1192 foreach ( (array) $rows as $option_name ) {
1193 $key = substr( Utils::to_str( $option_name ), strlen( $prefix ) );
1194 $timeout_option = $timeout_prefix . $key;
1195 $expiration = $is_network
1196 ? get_site_option( $timeout_option )
1197 : get_option( $timeout_option );
1198 $expiration = Utils::to_int( $expiration );
1199 $result[] = array(
1200 'key' => $key,
1201 'expiration' => $expiration,
1202 'expires_in' => $expiration > 0 ? max( 0, $expiration - $now ) : null,
1203 'expired' => $expiration > 0 && $expiration < $now,
1204 );
1205 }
1206
1207 return Response::success(
1208 array(
1209 'scope' => $is_network ? 'site' : 'blog',
1210 'count' => count( $result ),
1211 'items' => $result,
1212 )
1213 );
1214 }
1215
1216 /**
1217 * Handle "transient type" — return the storage backend.
1218 *
1219 * @return array<string,mixed>
1220 */
1221 private function handle_transient_type(): array {
1222 return Response::success(
1223 array(
1224 'storage' => wp_using_ext_object_cache() ? 'object-cache' : 'database',
1225 'using_ext_object_cache' => wp_using_ext_object_cache(),
1226 'multisite' => is_multisite(),
1227 // Hint for the LLM about where `transient list` is currently
1228 // reading from — when object-cache is on, the option-table
1229 // listing won't see transients that live only in the cache.
1230 'list_visibility' => wp_using_ext_object_cache()
1231 ? 'transient list reads the option/sitemeta table only; transients held entirely in the persistent object cache are NOT enumerated here.'
1232 : 'transient list enumerates every site/network transient from the option/sitemeta table.',
1233 )
1234 );
1235 }
1236
1237 /**
1238 * Handle "transient delete --expired | --all". Specific-key deletion
1239 * (`transient delete <key>`) is refused upstream by
1240 * `verify_command_security` — the LLM shouldn't be picking individual
1241 * transient keys to nuke.
1242 *
1243 * @param array<string,mixed> $flags Parsed flags.
1244 * @return array<string,mixed>
1245 */
1246 private function handle_transient_delete( array $flags ): array {
1247 $expired_only = ! empty( $flags['expired'] );
1248 $all = ! empty( $flags['all'] );
1249
1250 // Defence in depth — the security verifier already gates these two cases.
1251 if ( ! $expired_only && ! $all ) {
1252 return Response::error( 'Usage: transient delete --expired | --all' );
1253 }
1254 if ( $expired_only && $all ) {
1255 return Response::error( 'Pass either --expired or --all, not both.' );
1256 }
1257
1258 if ( $expired_only ) {
1259 $deleted = $this->delete_expired_transients( false );
1260 // On multisite also clean network transients for the current network.
1261 if ( is_multisite() ) {
1262 $deleted += $this->delete_expired_transients( true );
1263 }
1264 return Response::success(
1265 array(
1266 'mode' => 'expired',
1267 'transients_freed' => $deleted,
1268 'message' => sprintf( 'Deleted %d expired transient entr%s.', $deleted, 1 === $deleted ? 'y' : 'ies' ),
1269 )
1270 );
1271 }
1272
1273 // --all: nuke every transient. Mirrors `wp transient delete --all`.
1274 $deleted = $this->delete_all_transients( false );
1275 if ( is_multisite() ) {
1276 $deleted += $this->delete_all_transients( true );
1277 }
1278 if ( wp_using_ext_object_cache() ) {
1279 // Transients in the persistent object cache live outside the
1280 // options/sitemeta tables; flush the cache so they're gone too.
1281 wp_cache_flush();
1282 }
1283 return Response::success(
1284 array(
1285 'mode' => 'all',
1286 'transients_freed' => $deleted,
1287 'object_cache_flushed' => wp_using_ext_object_cache(),
1288 'message' => sprintf( 'Deleted %d transient entr%s.', $deleted, 1 === $deleted ? 'y' : 'ies' ),
1289 )
1290 );
1291 }
1292
1293 /**
1294 * Delete expired transient + timeout option pairs.
1295 *
1296 * @param bool $network When true, operate on `_site_transient_*` entries
1297 * in $wpdb->sitemeta. Otherwise blog-level options.
1298 * @return int Number of `_transient_*` (non-timeout) options removed.
1299 */
1300 private function delete_expired_transients( bool $network ): int {
1301 global $wpdb;
1302 /**
1303 * Narrowed type for `$wpdb`.
1304 *
1305 * @var \wpdb $wpdb
1306 */
1307 $now = time();
1308 if ( $network ) {
1309 $table = (string) $wpdb->sitemeta;
1310 $name_column = 'meta_key';
1311 $value_column = 'meta_value';
1312 $timeout_prefix = '_site_transient_timeout_';
1313 $transient_prefix = '_site_transient_';
1314 } else {
1315 $table = (string) $wpdb->options;
1316 $name_column = 'option_name';
1317 $value_column = 'option_value';
1318 $timeout_prefix = '_transient_timeout_';
1319 $transient_prefix = '_transient_';
1320 }
1321
1322 // Find expired timeout entries.
1323 $sql = 'SELECT %i FROM %i WHERE %i LIKE %s AND %i < %d';
1324 $args = array( $name_column, $table, $name_column, $wpdb->esc_like( $timeout_prefix ) . '%', $value_column, $now );
1325 if ( $network ) {
1326 $sql .= ' AND site_id = %d';
1327 $args[] = get_current_network_id();
1328 }
1329 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%s/%d placeholders and run through $wpdb->prepare().
1330 $expired_timeout_names = $wpdb->get_col( $wpdb->prepare( $sql, $args ) );
1331 if ( empty( $expired_timeout_names ) ) {
1332 return 0;
1333 }
1334
1335 $deleted = 0;
1336 foreach ( $expired_timeout_names as $timeout_option ) {
1337 $key = substr( Utils::to_str( $timeout_option ), strlen( $timeout_prefix ) );
1338 $pair = $transient_prefix . $key;
1339 if ( $network ) {
1340 if ( delete_site_transient( $key ) ) {
1341 ++$deleted;
1342 } else {
1343 // Belt-and-braces — drop dangling rows directly.
1344 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1345 $wpdb->delete(
1346 $table,
1347 array(
1348 $name_column => $pair,
1349 'site_id' => get_current_network_id(),
1350 )
1351 );
1352 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1353 $wpdb->delete(
1354 $table,
1355 array(
1356 $name_column => $timeout_option,
1357 'site_id' => get_current_network_id(),
1358 )
1359 );
1360 }
1361 } else {
1362 if ( delete_transient( $key ) ) {
1363 ++$deleted;
1364 } else {
1365 delete_option( $pair );
1366 delete_option( Utils::to_str( $timeout_option ) );
1367 }
1368 }
1369 }
1370 return $deleted;
1371 }
1372
1373 /**
1374 * Delete every transient (both `_transient_*` and `_transient_timeout_*`).
1375 *
1376 * Walks the discovered key list and calls core's `delete_transient()` /
1377 * `delete_site_transient()` for each one. Slower than a raw DELETE but
1378 * keeps the in-memory option/transient cache coherent — non-autoloaded
1379 * transient rows aren't in `alloptions`, so a bulk SQL DELETE alone
1380 * would leave stale values in the per-option cache.
1381 *
1382 * @param bool $network See delete_expired_transients().
1383 * @return int Number of transient entries removed.
1384 */
1385 private function delete_all_transients( bool $network ): int {
1386 global $wpdb;
1387 /**
1388 * Narrowed type for `$wpdb`.
1389 *
1390 * @var \wpdb $wpdb
1391 */
1392 if ( $network ) {
1393 $table = (string) $wpdb->sitemeta;
1394 $name_column = 'meta_key';
1395 $prefix = '_site_transient_';
1396 $timeout_prefix = '_site_transient_timeout_';
1397 } else {
1398 $table = (string) $wpdb->options;
1399 $name_column = 'option_name';
1400 $prefix = '_transient_';
1401 $timeout_prefix = '_transient_timeout_';
1402 }
1403
1404 // Discover every transient name (skip the timeout pairs — delete_*_transient
1405 // removes them along with the main entry).
1406 $sql = 'SELECT %i FROM %i WHERE %i LIKE %s AND %i NOT LIKE %s';
1407 $args = array( $name_column, $table, $name_column, $wpdb->esc_like( $prefix ) . '%', $name_column, $wpdb->esc_like( $timeout_prefix ) . '%' );
1408 if ( $network ) {
1409 $sql .= ' AND site_id = %d';
1410 $args[] = get_current_network_id();
1411 }
1412 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%s/%d placeholders and run through $wpdb->prepare().
1413 $names = $wpdb->get_col( $wpdb->prepare( $sql, $args ) );
1414 if ( empty( $names ) ) {
1415 return 0;
1416 }
1417
1418 $deleted = 0;
1419 foreach ( $names as $option_name ) {
1420 $key = substr( Utils::to_str( $option_name ), strlen( $prefix ) );
1421 if ( '' === $key ) {
1422 continue;
1423 }
1424 $ok = $network ? delete_site_transient( $key ) : delete_transient( $key );
1425 if ( $ok ) {
1426 ++$deleted;
1427 }
1428 }
1429 return $deleted;
1430 }
1431
1432 // ─── Pagination helpers ─────────────────────────────────────────────────────
1433
1434 /**
1435 * Resolve the page window (page size + zero-based offset) for a list
1436 * command from its flags.
1437 *
1438 * Accepts both the WP-CLI input style (`--posts_per_page` / `--number`,
1439 * `--offset`) and the WP REST style (`--per_page`, `--page`). When an
1440 * explicit `--offset` is absent it is derived from `--page` (1-based).
1441 *
1442 * @param array<string,mixed> $flags Parsed command flags.
1443 * @param string $size_flag Primary page-size flag for this
1444 * command (`posts_per_page` for
1445 * posts, `number` for users/terms).
1446 * @param int $default_size Fallback page size when none is given.
1447 * @return array{0:int,1:int} [per_page, offset] — both clamped to safe bounds.
1448 */
1449 private function resolve_page_window( array $flags, string $size_flag, int $default_size ): array {
1450 $raw = $flags[ $size_flag ] ?? $flags['per_page'] ?? null;
1451
1452 // `-1` (or any negative) is the WP-CLI idiom for "all rows". Return the
1453 // sentinel -1 so handlers can request an unbounded query instead of
1454 // silently clamping the universal "give me everything" request to a
1455 // single row.
1456 if ( null !== $raw && Utils::to_int( $raw ) < 0 ) {
1457 return array( -1, 0 );
1458 }
1459
1460 $per_page = Utils::to_int( $raw, $default_size );
1461 $per_page = max( 1, min( $per_page, 1000 ) );
1462
1463 if ( isset( $flags['offset'] ) ) {
1464 $offset = max( 0, Utils::to_int( $flags['offset'] ) );
1465 } elseif ( isset( $flags['page'] ) ) {
1466 $offset = ( max( 1, Utils::to_int( $flags['page'] ) ) - 1 ) * $per_page;
1467 } else {
1468 $offset = 0;
1469 }
1470
1471 return array( $per_page, $offset );
1472 }
1473
1474 /**
1475 * Wrap a page of rows with WP REST-standard pagination metadata.
1476 *
1477 * Mirrors the WP REST API contract (the `X-WP-Total` / `X-WP-TotalPages`
1478 * headers) in the response body so the agent can page deterministically
1479 * with `--page` instead of guessing completeness from row count — a
1480 * silently-capped page is otherwise indistinguishable from a full result.
1481 *
1482 * @param array<int|string,mixed> $items Rows for the current page.
1483 * @param int $total Total matching rows across every page.
1484 * @param int $per_page Page size actually applied.
1485 * @param int $offset Zero-based offset of the current page.
1486 * @return array<string,mixed> Standardized success response with `data.pagination`.
1487 */
1488 private function paginated_response( array $items, int $total, int $per_page, int $offset ): array {
1489 $per_page = max( 1, $per_page );
1490
1491 return Response::success(
1492 $items,
1493 array(
1494 'pagination' => array(
1495 'total' => $total,
1496 'total_pages' => (int) ceil( $total / $per_page ),
1497 'page' => (int) floor( $offset / $per_page ) + 1,
1498 'per_page' => $per_page,
1499 'has_more' => ( $offset + count( $items ) ) < $total,
1500 ),
1501 )
1502 );
1503 }
1504
1505 /**
1506 * Whether the command requested a bare count (`--format=count`), mirroring
1507 * WP-CLI's `--format=count` which prints only the total number of rows.
1508 *
1509 * @param array<string,mixed> $flags Parsed command flags.
1510 * @return bool
1511 */
1512 private function wants_count( array $flags ): bool {
1513 return 'count' === strtolower( Utils::to_str( $flags['format'] ?? '' ) );
1514 }
1515
1516 /**
1517 * Whether the command requested only IDs (`--format=ids`), mirroring
1518 * WP-CLI's `--format=ids` which prints just the row identifiers. The
1519 * native dispatcher returns them as a flat JSON array so the agent can
1520 * feed them straight into a follow-up bulk operation.
1521 *
1522 * @param array<string,mixed> $flags Parsed command flags.
1523 * @return bool
1524 */
1525 private function wants_ids( array $flags ): bool {
1526 return 'ids' === strtolower( Utils::to_str( $flags['format'] ?? '' ) );
1527 }
1528
1529 /**
1530 * Normalize a `--post_status` flag to a value WP_Query understands.
1531 *
1532 * - `all` → every registered status (incl. trash/draft), matching the
1533 * agent's intent of "every post regardless of status". WP_Query has no
1534 * native `all`, so left raw it silently matches nothing.
1535 * - Comma lists (`publish,draft`) → array, mirroring WP-CLI.
1536 * - Anything else (`publish`, `any`, …) → passed through untouched.
1537 *
1538 * @param mixed $status Raw flag value.
1539 * @return string|array<int|string,mixed>
1540 */
1541 private function normalize_post_status( $status ) {
1542 if ( is_array( $status ) ) {
1543 return $status;
1544 }
1545 $status = Utils::to_str( $status );
1546 if ( 'all' === strtolower( $status ) ) {
1547 return array_values( get_post_stati() );
1548 }
1549 if ( false !== strpos( $status, ',' ) ) {
1550 return array_values( array_filter( array_map( 'trim', explode( ',', $status ) ), static fn ( string $s ): bool => '' !== $s ) );
1551 }
1552 return $status;
1553 }
1554
1555 // ─── Post handlers ──────────────────────────────────────────────────────────
1556
1557 /**
1558 * Handle "post list".
1559 *
1560 * @param array<string,mixed> $flags Parsed flags.
1561 * @return array<string,mixed>
1562 */
1563 private function handle_post_list( array $flags ): array {
1564 $post_type = Utils::to_str( $flags['post_type'] ?? 'post', 'post' );
1565 // Attachments are ALWAYS stored with post_status='inherit', never
1566 // 'publish'. Defaulting to 'publish' (correct for posts/pages)
1567 // silently returns zero media for `post list --post_type=attachment`,
1568 // which made the agent report "no media files" on sites that have
1569 // plenty. Mirror WP-CLI: when no --post_status is given, attachments
1570 // default to 'inherit'; everything else keeps 'publish'. An explicit
1571 // `--post_status=any` still overrides either way.
1572 $default_status = 'attachment' === $post_type ? 'inherit' : 'publish';
1573 list( $per_page, $offset ) = $this->resolve_page_window( $flags, 'posts_per_page', 20 );
1574 $all_rows = ( -1 === $per_page );
1575 $query_args = array(
1576 'post_type' => $post_type,
1577 'post_status' => $this->normalize_post_status( $flags['post_status'] ?? $default_status ),
1578 'posts_per_page' => $all_rows ? -1 : $per_page,
1579 'offset' => $all_rows ? 0 : $offset,
1580 'orderby' => $flags['orderby'] ?? 'date',
1581 'order' => strtoupper( Utils::to_str( $flags['order'] ?? 'DESC', 'DESC' ) ),
1582 );
1583 if ( ! empty( $flags['s'] ) ) {
1584 $query_args['s'] = Utils::to_str( $flags['s'] );
1585 }
1586
1587 // `--format=count`: mirror WP-CLI and return only the total, computed
1588 // from found_posts so it is accurate regardless of page size.
1589 if ( $this->wants_count( $flags ) ) {
1590 $count_query = new \WP_Query(
1591 array_merge(
1592 $query_args,
1593 array(
1594 'fields' => 'ids',
1595 'posts_per_page' => 1,
1596 'offset' => 0,
1597 )
1598 )
1599 );
1600 return Response::success( (int) $count_query->found_posts );
1601 }
1602
1603 // `--format=ids`: flat list of post IDs for chaining into bulk ops.
1604 if ( $this->wants_ids( $flags ) ) {
1605 $id_query = new \WP_Query( array_merge( $query_args, array( 'fields' => 'ids' ) ) );
1606 $ids = array_map( static fn ( $p ) => is_scalar( $p ) ? (int) $p : 0, $id_query->posts );
1607 return $this->paginated_response(
1608 $ids,
1609 (int) $id_query->found_posts,
1610 $all_rows ? max( 1, (int) $id_query->found_posts ) : $per_page,
1611 $all_rows ? 0 : $offset
1612 );
1613 }
1614
1615 $fields = $this->resolve_fields_flag( $flags, array( 'ID', 'post_title', 'post_status', 'post_date', 'post_type' ) );
1616
1617 $query = new \WP_Query( $query_args );
1618 $out = array();
1619 foreach ( $query->posts as $post ) {
1620 if ( ! $post instanceof \WP_Post ) {
1621 continue;
1622 }
1623 $out[] = $this->post_to_array( $post, $fields );
1624 }
1625 $total = (int) $query->found_posts;
1626 return $this->paginated_response( $out, $total, $all_rows ? max( 1, $total ) : $per_page, $all_rows ? 0 : $offset );
1627 }
1628
1629 /**
1630 * Handle "post get".
1631 *
1632 * @param string[] $positional Remaining positional args.
1633 * @param array<string,mixed> $flags Parsed flags.
1634 * @return array<string,mixed>
1635 */
1636 private function handle_post_get( array $positional, array $flags ) {
1637 $post_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1638 if ( $post_id <= 0 ) {
1639 return Response::error( 'Usage: post get <ID>' );
1640 }
1641 $post = get_post( $post_id );
1642 if ( ! $post ) {
1643 return Response::error( sprintf( 'Post %d not found.', $post_id ) );
1644 }
1645 $fields = $this->resolve_fields_flag( $flags, null );
1646 return Response::success( $this->post_to_array( $post, $fields ) );
1647 }
1648
1649 /**
1650 * Handle "post create".
1651 *
1652 * @param array<string,mixed> $flags Parsed flags.
1653 * @return array<string,mixed>
1654 */
1655 private function handle_post_create( array $flags ) {
1656 $data = $this->flags_to_post_data( $flags );
1657 if ( isset( $data['post_type'] ) && $this->is_managed_post_type( $data['post_type'] ) ) {
1658 return $this->managed_post_type_error( $data['post_type'] );
1659 }
1660 // wp_insert_post() runs wp_unslash() on the data — without wp_slash()
1661 // a literal backslash in a title/excerpt ("C:\new deals") is eaten.
1662 // The comment handlers on the identical contract already slash.
1663 $result = wp_insert_post( wp_slash( $data ), true );
1664 if ( is_wp_error( $result ) ) {
1665 return Response::error( $result->get_error_message() );
1666 }
1667 return Response::success( array( 'ID' => (int) $result ) );
1668 }
1669
1670 /**
1671 * Handle "post update".
1672 *
1673 * @param string[] $positional Remaining positional args.
1674 * @param array<string,mixed> $flags Parsed flags.
1675 * @return array<string,mixed>
1676 */
1677 private function handle_post_update( array $positional, array $flags ) {
1678 $post_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1679 if ( $post_id <= 0 ) {
1680 return Response::error( 'Usage: post update <ID> --field=value ...' );
1681 }
1682 // Site-infrastructure posts are off-limits for the GENERIC post
1683 // primitive in BOTH directions: retyping a managed post detaches it
1684 // (a wp_template_part turned "post" drops the site chrome), and any
1685 // sibling-field write on one degrades it (drafting a wp_global_styles
1686 // post pulls the design tokens off the front end) — the exact outcome
1687 // the --post_content guard already exists to prevent, one field over.
1688 $target = get_post( $post_id );
1689 if ( $target && $this->is_managed_post_type( (string) $target->post_type ) ) {
1690 return $this->managed_post_type_error( (string) $target->post_type );
1691 }
1692 $data = $this->flags_to_post_data( $flags );
1693 if ( isset( $data['post_type'] ) && $this->is_managed_post_type( $data['post_type'] ) ) {
1694 return $this->managed_post_type_error( $data['post_type'] );
1695 }
1696 $data['ID'] = $post_id;
1697 // wp_update_post() runs wp_unslash() on the data — see handle_post_create.
1698 $result = wp_update_post( wp_slash( $data ), true );
1699 if ( is_wp_error( $result ) ) {
1700 return Response::error( $result->get_error_message() );
1701 }
1702 return Response::success( array( 'ID' => (int) $result ) );
1703 }
1704
1705 /**
1706 * Post types the generic post primitives must not touch — WordPress site
1707 * infrastructure owned by dedicated tools (template editing, navigation,
1708 * global styles, the font library).
1709 *
1710 * @param string $post_type Post type slug.
1711 * @return bool
1712 */
1713 private function is_managed_post_type( string $post_type ): bool {
1714 return in_array(
1715 $post_type,
1716 array( 'wp_global_styles', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_font_family', 'wp_font_face' ),
1717 true
1718 );
1719 }
1720
1721 /**
1722 * The refusal for a managed post type, pointing at the owning tools.
1723 *
1724 * @param string $post_type Post type slug.
1725 * @return array<string,mixed>
1726 */
1727 private function managed_post_type_error( string $post_type ): array {
1728 return Response::error(
1729 sprintf(
1730 'Refused: `%s` posts are WordPress site infrastructure (templates, navigation, global styles, fonts). Generic post commands cannot create, retype, restate or delete them. Use the dedicated theme/navigation/style tools instead.',
1731 $post_type
1732 )
1733 );
1734 }
1735
1736 /**
1737 * Handle "post delete <ID> [<ID2> …] [--force]".
1738 *
1739 * Mirrors WP-CLI's native multi-id syntax. `wp post delete 12 34 56`
1740 * processes all three ids. The earlier single-id code dropped everything
1741 * after positional[0]. It reported success on the partial outcome. The LLM
1742 * could not know it deleted too few posts.
1743 *
1744 * Partial-success semantics:
1745 * - At least one delete works → `success: true`. The result holds
1746 * `deleted` (the list of ids that landed) and `failed` (a reason per
1747 * id). The caller phrases the response on the real counts.
1748 * - No delete works (every id is missing or a hook refused it) →
1749 * `Response::error`. So the caller does not claim success on a no-op
1750 * turn.
1751 *
1752 * @param string[] $positional Remaining positional args (one or more post IDs).
1753 * @param array<string,mixed> $flags Parsed flags (`--force` recognised).
1754 * @return array<string,mixed>
1755 */
1756 private function handle_post_delete( array $positional, array $flags ): array {
1757 if ( empty( $positional ) ) {
1758 return Response::error( 'Usage: post delete <ID> [<ID2> …] [--force]' );
1759 }
1760 $force = ! empty( $flags['force'] );
1761
1762 $deleted = array();
1763 $failed = array();
1764
1765 foreach ( $positional as $arg ) {
1766 $post_id = (int) $arg;
1767 if ( $post_id <= 0 ) {
1768 $failed[] = array(
1769 'id' => (string) $arg,
1770 'reason' => 'not a positive integer',
1771 );
1772 continue;
1773 }
1774 // Same infrastructure guard as post update — deleting a
1775 // wp_template_part / wp_navigation / wp_global_styles post through
1776 // the generic primitive detaches site chrome or design tokens.
1777 $target = get_post( $post_id );
1778 if ( $target && $this->is_managed_post_type( (string) $target->post_type ) ) {
1779 $failed[] = array(
1780 'id' => $post_id,
1781 'reason' => "post type `{$target->post_type}` is site infrastructure, use the dedicated theme/navigation/style tools",
1782 );
1783 continue;
1784 }
1785 $result = wp_delete_post( $post_id, $force );
1786 if ( $result ) {
1787 $deleted[] = $post_id;
1788 } else {
1789 $failed[] = array(
1790 'id' => $post_id,
1791 'reason' => 'wp_delete_post returned false (post not found, already trashed when --force omitted, or hook refused)',
1792 );
1793 }
1794 }
1795
1796 // All-fail → error, so the caller doesn't claim success on zero deletes.
1797 // Mirrors the same truth-telling rule core__bulk_run_wp_cli enforces:
1798 // if NOTHING landed, don't mark dependent todos done.
1799 if ( empty( $deleted ) ) {
1800 $ids = array_map( static fn( $f ) => (string) $f['id'], $failed );
1801 $details = array_map( static fn( $f ) => $f['id'] . ' (' . $f['reason'] . ')', $failed );
1802 return Response::error(
1803 sprintf(
1804 'No posts deleted. Attempted IDs: %s. Failures: %s.',
1805 implode( ', ', $ids ),
1806 implode( '; ', $details )
1807 )
1808 );
1809 }
1810
1811 return Response::success(
1812 array(
1813 'deleted' => $deleted,
1814 'failed' => $failed,
1815 'forced' => $force,
1816 )
1817 );
1818 }
1819
1820 // ─── Comment handlers ───────────────────────────────────────────────────────
1821
1822 /**
1823 * Handle "comment list".
1824 *
1825 * @param array<string,mixed> $flags Parsed flags.
1826 * @return array<string,mixed>
1827 */
1828 private function handle_comment_list( array $flags ): array {
1829 list( $per_page, $offset ) = $this->resolve_page_window( $flags, 'number', 20 );
1830 $all_rows = ( -1 === $per_page );
1831 $query_args = array(
1832 // WP_Comment_Query's 'all' covers approved + pending; spam/trash
1833 // rows only appear when requested explicitly via --status.
1834 'status' => Utils::to_str( $flags['status'] ?? 'all', 'all' ),
1835 'number' => $all_rows ? 0 : $per_page,
1836 'offset' => $all_rows ? 0 : $offset,
1837 'orderby' => $flags['orderby'] ?? 'comment_date_gmt',
1838 'order' => strtoupper( Utils::to_str( $flags['order'] ?? 'DESC', 'DESC' ) ),
1839 );
1840 // WP-CLI uses --comment_post_ID; accept --post_id as the friendlier alias.
1841 $post_id = Utils::to_int( $flags['comment_post_ID'] ?? $flags['post_id'] ?? 0 );
1842 if ( $post_id > 0 ) {
1843 $query_args['post_id'] = $post_id;
1844 }
1845 if ( ! empty( $flags['search'] ) ) {
1846 $query_args['search'] = Utils::to_str( $flags['search'] );
1847 }
1848 if ( ! empty( $flags['type'] ) ) {
1849 $query_args['type'] = Utils::to_str( $flags['type'] );
1850 }
1851
1852 $count_args = array_merge(
1853 $query_args,
1854 array(
1855 'count' => true,
1856 'number' => 0,
1857 'offset' => 0,
1858 )
1859 );
1860
1861 // `--format=count`: total matching comments, no rows fetched.
1862 if ( $this->wants_count( $flags ) ) {
1863 return Response::success( (int) ( new \WP_Comment_Query() )->query( $count_args ) );
1864 }
1865
1866 $total = (int) ( new \WP_Comment_Query() )->query( $count_args );
1867
1868 // `--format=ids`: flat list of comment IDs for chaining into bulk ops.
1869 if ( $this->wants_ids( $flags ) ) {
1870 $ids = (array) ( new \WP_Comment_Query() )->query( array_merge( $query_args, array( 'fields' => 'ids' ) ) );
1871 return $this->paginated_response(
1872 array_map( static fn ( $id ) => is_scalar( $id ) ? (int) $id : 0, $ids ),
1873 $total,
1874 $all_rows ? max( 1, $total ) : $per_page,
1875 $all_rows ? 0 : $offset
1876 );
1877 }
1878
1879 $fields = $this->resolve_fields_flag(
1880 $flags,
1881 array( 'comment_ID', 'comment_post_ID', 'comment_author', 'comment_author_email', 'comment_date', 'status' )
1882 );
1883 $comments = (array) ( new \WP_Comment_Query() )->query( $query_args );
1884 $out = array();
1885 foreach ( $comments as $comment ) {
1886 if ( ! $comment instanceof \WP_Comment ) {
1887 continue;
1888 }
1889 $out[] = $this->comment_to_array( $comment, $fields );
1890 }
1891 return $this->paginated_response( $out, $total, $all_rows ? max( 1, $total ) : $per_page, $all_rows ? 0 : $offset );
1892 }
1893
1894 /**
1895 * Handle "comment get".
1896 *
1897 * @param string[] $positional Remaining positional args.
1898 * @param array<string,mixed> $flags Parsed flags.
1899 * @return array<string,mixed>
1900 */
1901 private function handle_comment_get( array $positional, array $flags ) {
1902 $comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1903 if ( $comment_id <= 0 ) {
1904 return Response::error( 'Usage: comment get <ID>' );
1905 }
1906 $comment = get_comment( $comment_id );
1907 if ( ! $comment ) {
1908 return Response::error( sprintf( 'Comment %d not found.', $comment_id ) );
1909 }
1910 $fields = $this->resolve_fields_flag( $flags, null );
1911 return Response::success( $this->comment_to_array( $comment, $fields ) );
1912 }
1913
1914 /**
1915 * Handle "comment count [<post-ID>]".
1916 *
1917 * @param string[] $positional Remaining positional args.
1918 * @return array<string,mixed>
1919 */
1920 private function handle_comment_count( array $positional ): array {
1921 $post_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1922 $counts = wp_count_comments( $post_id );
1923 return Response::success( (array) $counts );
1924 }
1925
1926 /**
1927 * Handle "comment exists <ID>".
1928 *
1929 * @param string[] $positional Remaining positional args.
1930 * @return array<string,mixed>
1931 */
1932 private function handle_comment_exists( array $positional ): array {
1933 $comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1934 if ( $comment_id <= 0 ) {
1935 return Response::error( 'Usage: comment exists <ID>' );
1936 }
1937 return Response::success( array( 'exists' => (bool) get_comment( $comment_id ) ) );
1938 }
1939
1940 /**
1941 * Handle "comment status <ID>".
1942 *
1943 * @param string[] $positional Remaining positional args.
1944 * @return array<string,mixed>
1945 */
1946 private function handle_comment_status( array $positional ): array {
1947 $comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1948 if ( $comment_id <= 0 ) {
1949 return Response::error( 'Usage: comment status <ID>' );
1950 }
1951 $status = wp_get_comment_status( $comment_id );
1952 if ( false === $status ) {
1953 return Response::error( sprintf( 'Comment %d not found.', $comment_id ) );
1954 }
1955 return Response::success( array( 'status' => $status ) );
1956 }
1957
1958 /**
1959 * Handle "comment create".
1960 *
1961 * @param array<string,mixed> $flags Parsed flags.
1962 * @return array<string,mixed>
1963 */
1964 private function handle_comment_create( array $flags ) {
1965 $data = $this->flags_to_comment_data( $flags );
1966
1967 $post_id = (int) ( $data['comment_post_ID'] ?? 0 );
1968 if ( $post_id <= 0 ) {
1969 return Response::error( 'Usage: comment create --comment_post_ID=<post-ID> --comment_content="…" [--comment_author=…] [--comment_author_email=…] [--comment_approved=0|1]' );
1970 }
1971 if ( ! get_post( $post_id ) ) {
1972 return Response::error( sprintf( 'Post %d not found — cannot attach a comment to it.', $post_id ) );
1973 }
1974
1975 // Mirror WP-CLI: comments created by the tool are approved unless told otherwise.
1976 if ( ! array_key_exists( 'comment_approved', $data ) ) {
1977 $data['comment_approved'] = 1;
1978 }
1979
1980 $comment_id = wp_insert_comment( wp_slash( $data ) );
1981 if ( ! $comment_id ) {
1982 return Response::error( 'wp_insert_comment failed (hook refused or invalid data).' );
1983 }
1984 return Response::success( array( 'comment_ID' => (int) $comment_id ) );
1985 }
1986
1987 /**
1988 * Handle "comment update".
1989 *
1990 * @param string[] $positional Remaining positional args.
1991 * @param array<string,mixed> $flags Parsed flags.
1992 * @return array<string,mixed>
1993 */
1994 private function handle_comment_update( array $positional, array $flags ) {
1995 $comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1996 if ( $comment_id <= 0 ) {
1997 return Response::error( 'Usage: comment update <ID> --field=value ...' );
1998 }
1999 if ( ! get_comment( $comment_id ) ) {
2000 return Response::error( sprintf( 'Comment %d not found.', $comment_id ) );
2001 }
2002 $data = $this->flags_to_comment_data( $flags );
2003 if ( empty( $data ) ) {
2004 return Response::error( 'No updatable fields supplied. Pass flags like --comment_content="…".' );
2005 }
2006 $data['comment_ID'] = $comment_id;
2007
2008 $result = wp_update_comment( wp_slash( $data ), true );
2009 if ( is_wp_error( $result ) ) {
2010 return Response::error( $result->get_error_message() );
2011 }
2012 return Response::success(
2013 array(
2014 'comment_ID' => $comment_id,
2015 'updated' => (bool) $result,
2016 )
2017 );
2018 }
2019
2020 /**
2021 * Handle "comment delete <ID> [<ID2> …] [--force]".
2022 *
2023 * Same partial-success semantics as `post delete`: report what landed
2024 * and what failed; all-fail → error so the caller doesn't claim success
2025 * on a no-op turn. Without --force the comment is trashed (WP default);
2026 * with --force it is permanently deleted.
2027 *
2028 * @param string[] $positional Remaining positional args (one or more comment IDs).
2029 * @param array<string,mixed> $flags Parsed flags (`--force` recognised).
2030 * @return array<string,mixed>
2031 */
2032 private function handle_comment_delete( array $positional, array $flags ): array {
2033 if ( empty( $positional ) ) {
2034 return Response::error( 'Usage: comment delete <ID> [<ID2> …] [--force]' );
2035 }
2036 $force = ! empty( $flags['force'] );
2037
2038 $deleted = array();
2039 $failed = array();
2040
2041 foreach ( $positional as $arg ) {
2042 $comment_id = (int) $arg;
2043 if ( $comment_id <= 0 ) {
2044 $failed[] = array(
2045 'id' => (string) $arg,
2046 'reason' => 'not a positive integer',
2047 );
2048 continue;
2049 }
2050 $result = wp_delete_comment( $comment_id, $force );
2051 if ( $result ) {
2052 $deleted[] = $comment_id;
2053 } else {
2054 $failed[] = array(
2055 'id' => $comment_id,
2056 'reason' => 'wp_delete_comment returned false (comment not found, already trashed when --force omitted, or hook refused)',
2057 );
2058 }
2059 }
2060
2061 if ( empty( $deleted ) ) {
2062 $ids = array_map( static fn( $f ) => (string) $f['id'], $failed );
2063 $details = array_map( static fn( $f ) => $f['id'] . ' (' . $f['reason'] . ')', $failed );
2064 return Response::error(
2065 sprintf(
2066 'No comments deleted. Attempted IDs: %s. Failures: %s.',
2067 implode( ', ', $ids ),
2068 implode( '; ', $details )
2069 )
2070 );
2071 }
2072
2073 return Response::success(
2074 array(
2075 'deleted' => $deleted,
2076 'failed' => $failed,
2077 'forced' => $force,
2078 )
2079 );
2080 }
2081
2082 /**
2083 * Handle "comment approve|unapprove|spam|unspam|trash|untrash <ID> [<ID2> …]".
2084 *
2085 * Same partial-success semantics as `comment delete`.
2086 *
2087 * @param string $verb Moderation verb.
2088 * @param string[] $positional One or more comment IDs.
2089 * @return array<string,mixed>
2090 */
2091 private function handle_comment_set_status( string $verb, array $positional ): array {
2092 if ( empty( $positional ) ) {
2093 return Response::error( sprintf( 'Usage: comment %s <ID> [<ID2> …]', $verb ) );
2094 }
2095
2096 $changed = array();
2097 $failed = array();
2098
2099 foreach ( $positional as $arg ) {
2100 $comment_id = (int) $arg;
2101 if ( $comment_id <= 0 ) {
2102 $failed[] = array(
2103 'id' => (string) $arg,
2104 'reason' => 'not a positive integer',
2105 );
2106 continue;
2107 }
2108 if ( ! get_comment( $comment_id ) ) {
2109 $failed[] = array(
2110 'id' => $comment_id,
2111 'reason' => 'comment not found',
2112 );
2113 continue;
2114 }
2115
2116 switch ( $verb ) {
2117 case 'approve':
2118 $result = wp_set_comment_status( $comment_id, 'approve', true );
2119 break;
2120 case 'unapprove':
2121 $result = wp_set_comment_status( $comment_id, 'hold', true );
2122 break;
2123 case 'spam':
2124 $result = wp_spam_comment( $comment_id );
2125 break;
2126 case 'unspam':
2127 $result = wp_unspam_comment( $comment_id );
2128 break;
2129 case 'trash':
2130 $result = wp_trash_comment( $comment_id );
2131 break;
2132 case 'untrash':
2133 $result = wp_untrash_comment( $comment_id );
2134 break;
2135 default:
2136 return Response::error( sprintf( 'Unsupported comment moderation verb: "%s".', $verb ) );
2137 }
2138
2139 if ( is_wp_error( $result ) ) {
2140 $failed[] = array(
2141 'id' => $comment_id,
2142 'reason' => $result->get_error_message(),
2143 );
2144 } elseif ( $result ) {
2145 $changed[] = $comment_id;
2146 } else {
2147 $failed[] = array(
2148 'id' => $comment_id,
2149 'reason' => 'status change refused (already in target status, or hook refused)',
2150 );
2151 }
2152 }
2153
2154 if ( empty( $changed ) ) {
2155 $details = array_map( static fn( $f ) => $f['id'] . ' (' . $f['reason'] . ')', $failed );
2156 return Response::error(
2157 sprintf(
2158 'No comments %sd. Failures: %s.',
2159 $verb,
2160 implode( '; ', $details )
2161 )
2162 );
2163 }
2164
2165 return Response::success(
2166 array(
2167 'action' => $verb,
2168 'changed' => $changed,
2169 'failed' => $failed,
2170 )
2171 );
2172 }
2173
2174 /**
2175 * Handle "comment recount <post-ID> [<post-ID2> …]".
2176 *
2177 * Recalculates the cached comment_count on each post.
2178 *
2179 * @param string[] $positional One or more post IDs.
2180 * @return array<string,mixed>
2181 */
2182 private function handle_comment_recount( array $positional ): array {
2183 if ( empty( $positional ) ) {
2184 return Response::error( 'Usage: comment recount <post-ID> [<post-ID2> …]' );
2185 }
2186
2187 $out = array();
2188 foreach ( $positional as $arg ) {
2189 $post_id = (int) $arg;
2190 $post = $post_id > 0 ? get_post( $post_id ) : null;
2191 if ( ! $post ) {
2192 $out[] = array(
2193 'post_ID' => (string) $arg,
2194 'error' => 'post not found',
2195 );
2196 continue;
2197 }
2198 wp_update_comment_count_now( $post_id );
2199 $out[] = array(
2200 'post_ID' => $post_id,
2201 'comment_count' => (int) get_post( $post_id )->comment_count,
2202 );
2203 }
2204 return Response::success( $out );
2205 }
2206
2207 /**
2208 * Serialise a WP_Comment to a JSON-friendly array.
2209 *
2210 * Includes a derived `status` (approved/unapproved/spam/trash) so the
2211 * agent never has to decode raw comment_approved values ('0'/'1'/'spam').
2212 *
2213 * @param \WP_Comment $comment Comment.
2214 * @param string[]|null $fields Field subset; null for all.
2215 * @return array<string,mixed>
2216 */
2217 private function comment_to_array( \WP_Comment $comment, ?array $fields ): array {
2218 $all = array(
2219 'comment_ID' => (int) $comment->comment_ID,
2220 'comment_post_ID' => (int) $comment->comment_post_ID,
2221 'comment_author' => $comment->comment_author,
2222 'comment_author_email' => $comment->comment_author_email,
2223 'comment_author_url' => $comment->comment_author_url,
2224 'comment_date' => $comment->comment_date,
2225 'comment_content' => $comment->comment_content,
2226 'comment_type' => $comment->comment_type,
2227 'comment_parent' => (int) $comment->comment_parent,
2228 'comment_approved' => $comment->comment_approved,
2229 'user_id' => (int) $comment->user_id,
2230 'status' => wp_get_comment_status( $comment ),
2231 );
2232 if ( null === $fields ) {
2233 return $all;
2234 }
2235 $out = array();
2236 foreach ( $fields as $f ) {
2237 $out[ $f ] = $all[ $f ] ?? null;
2238 }
2239 return $out;
2240 }
2241
2242 /**
2243 * Translate WP-CLI-style --flags into a wp_insert_comment/wp_update_comment array.
2244 *
2245 * @param array<string,mixed> $flags Parsed flags.
2246 * @return array{comment_post_ID?:int,comment_content?:string,comment_author?:string,comment_author_email?:string,comment_author_url?:string,comment_approved?:string,comment_parent?:int,comment_type?:string,comment_date?:string,user_id?:int}
2247 */
2248 private function flags_to_comment_data( array $flags ): array {
2249 $out = array();
2250 if ( array_key_exists( 'comment_post_ID', $flags ) ) {
2251 $out['comment_post_ID'] = Utils::to_int( $flags['comment_post_ID'] );
2252 }
2253 if ( array_key_exists( 'comment_content', $flags ) ) {
2254 $out['comment_content'] = Utils::to_str( $flags['comment_content'] );
2255 }
2256 if ( array_key_exists( 'comment_author', $flags ) ) {
2257 $out['comment_author'] = Utils::to_str( $flags['comment_author'] );
2258 }
2259 if ( array_key_exists( 'comment_author_email', $flags ) ) {
2260 $out['comment_author_email'] = Utils::to_str( $flags['comment_author_email'] );
2261 }
2262 if ( array_key_exists( 'comment_author_url', $flags ) ) {
2263 $out['comment_author_url'] = Utils::to_str( $flags['comment_author_url'] );
2264 }
2265 if ( array_key_exists( 'comment_approved', $flags ) ) {
2266 $out['comment_approved'] = Utils::to_str( $flags['comment_approved'] );
2267 }
2268 if ( array_key_exists( 'comment_parent', $flags ) ) {
2269 $out['comment_parent'] = Utils::to_int( $flags['comment_parent'] );
2270 }
2271 if ( array_key_exists( 'comment_type', $flags ) ) {
2272 $out['comment_type'] = Utils::to_str( $flags['comment_type'] );
2273 }
2274 if ( array_key_exists( 'comment_date', $flags ) ) {
2275 $out['comment_date'] = Utils::to_str( $flags['comment_date'] );
2276 }
2277 if ( array_key_exists( 'user_id', $flags ) ) {
2278 $out['user_id'] = Utils::to_int( $flags['user_id'] );
2279 }
2280 return $out;
2281 }
2282
2283 // ─── Meta (post + user + comment) ───────────────────────────────────────────
2284
2285 /**
2286 * Route post-meta / user-meta / comment-meta subcommands.
2287 *
2288 * @param string $object "post", "user" or "comment".
2289 * @param string[] $positional Remaining positional args after "<object> meta".
2290 * @param array<string,mixed> $flags Parsed flags.
2291 * @return array<string,mixed>
2292 */
2293 private function route_meta( string $object, array $positional, array $flags ): array {
2294 unset( $flags );
2295 $verb = strtolower( $positional[0] ?? '' );
2296 $id = isset( $positional[1] ) ? (int) $positional[1] : 0;
2297
2298 // Writes are blocked in verify_command_security(); only reads reach here.
2299 $get_fns = array(
2300 'user' => 'get_user_meta',
2301 'comment' => 'get_comment_meta',
2302 );
2303 $get_fn = $get_fns[ $object ] ?? 'get_post_meta';
2304
2305 if ( 'get' === $verb ) {
2306 $key = $positional[2] ?? '';
2307 if ( $id <= 0 || '' === $key ) {
2308 return Response::error( sprintf( 'Usage: %s meta get <id> <key>', $object ) );
2309 }
2310 return Response::success( array( 'value' => $get_fn( $id, $key, true ) ) );
2311 }
2312
2313 if ( 'list' === $verb ) {
2314 if ( $id <= 0 ) {
2315 return Response::error( sprintf( 'Usage: %s meta list <id>', $object ) );
2316 }
2317 // Return all meta as { key: value } — single-element arrays are
2318 // unwrapped to the scalar so the LLM sees a clean key/value map.
2319 $all = $get_fn( $id );
2320 if ( ! is_array( $all ) ) {
2321 return Response::success( array() );
2322 }
2323 $out = array();
2324 foreach ( $all as $k => $v ) {
2325 if ( is_array( $v ) && 1 === count( $v ) ) {
2326 $first = reset( $v );
2327 $out[ $k ] = is_string( $first ) ? maybe_unserialize( $first ) : $first;
2328 } else {
2329 $out[ $k ] = $v;
2330 }
2331 }
2332 return Response::success( $out );
2333 }
2334
2335 // Anything else (add/update/set/delete/patch) reaches this only if the
2336 // security check failed to fire — defensive fallback with a clear
2337 // "not supported" rather than the misleading "writes are blocked".
2338 if ( in_array( $verb, array( 'add', 'update', 'set', 'delete', 'patch' ), true ) ) {
2339 return Response::error( sprintf( 'Security policy: %s meta writes are blocked. Use a dedicated ability for the specific meta key.', $object ) );
2340 }
2341 return Response::error( sprintf( 'Unsupported %s meta verb: "%s". Supported: get, list.', $object, $verb ) );
2342 }
2343
2344 // ─── User handlers ──────────────────────────────────────────────────────────
2345
2346 /**
2347 * Handle "user list".
2348 *
2349 * @param array<string,mixed> $flags Parsed flags.
2350 * @return array<string,mixed>
2351 */
2352 private function handle_user_list( array $flags ): array {
2353 list( $per_page, $offset ) = $this->resolve_page_window( $flags, 'number', 50 );
2354 $all_rows = ( -1 === $per_page );
2355 $query_args = array(
2356 'number' => $all_rows ? -1 : $per_page,
2357 'offset' => $all_rows ? 0 : $offset,
2358 'count_total' => true,
2359 );
2360 if ( ! empty( $flags['role'] ) ) {
2361 $query_args['role'] = Utils::to_str( $flags['role'] );
2362 }
2363 if ( ! empty( $flags['search'] ) ) {
2364 $query_args['search'] = '*' . Utils::to_str( $flags['search'] ) . '*';
2365 }
2366
2367 // `--format=count`: total matching users, no rows fetched.
2368 if ( $this->wants_count( $flags ) ) {
2369 $count_query = new \WP_User_Query(
2370 array_merge(
2371 $query_args,
2372 array(
2373 'number' => 1,
2374 'offset' => 0,
2375 'fields' => 'ID',
2376 )
2377 )
2378 );
2379 return Response::success( (int) $count_query->get_total() );
2380 }
2381
2382 // `--format=ids`: flat list of user IDs.
2383 if ( $this->wants_ids( $flags ) ) {
2384 $id_query = new \WP_User_Query( array_merge( $query_args, array( 'fields' => 'ID' ) ) );
2385 $ids = array_map( static fn ( $u ) => is_scalar( $u ) ? (int) $u : 0, $id_query->get_results() );
2386 return $this->paginated_response(
2387 $ids,
2388 (int) $id_query->get_total(),
2389 $all_rows ? max( 1, (int) $id_query->get_total() ) : $per_page,
2390 $all_rows ? 0 : $offset
2391 );
2392 }
2393
2394 $query = new \WP_User_Query( $query_args );
2395 $fields = $this->resolve_fields_flag( $flags, array( 'ID', 'user_login', 'user_email', 'display_name', 'roles' ) );
2396
2397 $out = array();
2398 foreach ( $query->get_results() as $user ) {
2399 if ( ! $user instanceof \WP_User ) {
2400 continue;
2401 }
2402 $out[] = $this->user_to_array( $user, $fields );
2403 }
2404 $total = (int) $query->get_total();
2405 return $this->paginated_response( $out, $total, $all_rows ? max( 1, $total ) : $per_page, $all_rows ? 0 : $offset );
2406 }
2407
2408 /**
2409 * Handle "user get".
2410 *
2411 * @param string[] $positional Remaining positional args.
2412 * @param array<string,mixed> $flags Parsed flags.
2413 * @return array<string,mixed>
2414 */
2415 private function handle_user_get( array $positional, array $flags ) {
2416 $ident = $positional[0] ?? '';
2417 if ( '' === $ident ) {
2418 return Response::error( 'Usage: user get <id-or-login>' );
2419 }
2420 $user = is_numeric( $ident ) ? get_user_by( 'id', (int) $ident ) : get_user_by( 'login', $ident );
2421 if ( ! $user ) {
2422 return Response::error( sprintf( 'User "%s" not found.', $ident ) );
2423 }
2424 $fields = $this->resolve_fields_flag( $flags, null );
2425 return Response::success( $this->user_to_array( $user, $fields ) );
2426 }
2427
2428 /**
2429 * Handle `user add-role|remove-role|set-role <id-or-login> <role>`.
2430 *
2431 * Admin/super-admin roles are refused in `verify_command_security()`;
2432 * by the time we reach here the role is editor/author/contributor/
2433 * subscriber/custom-non-admin.
2434 *
2435 * @param string $action 'add' | 'remove' | 'set'.
2436 * @param string[] $positional Remaining tokens — [id-or-login, role].
2437 * @return array<string,mixed>
2438 */
2439 private function handle_user_role_change( string $action, array $positional ): array {
2440 $ident = $positional[0] ?? '';
2441 $role = strtolower( $positional[1] ?? '' );
2442 if ( '' === $ident || '' === $role ) {
2443 return Response::error( sprintf( 'Usage: user %s-role <id-or-login> <role>', $action ) );
2444 }
2445 // Defense in depth — the security verifier already blocks
2446 // administrator/super-admin, but a future caller (or a verifier
2447 // regression) shouldn't be able to elevate a user here.
2448 if ( in_array( $role, array( 'administrator', 'super-admin' ), true ) ) {
2449 return Response::error( sprintf( 'Security policy: role "%s" cannot be changed via this endpoint.', $role ) );
2450 }
2451 $user = is_numeric( $ident ) ? get_user_by( 'id', (int) $ident ) : get_user_by( 'login', $ident );
2452 if ( ! $user ) {
2453 return Response::error( sprintf( 'User "%s" not found.', $ident ) );
2454 }
2455 // Validate the role exists.
2456 if ( ! get_role( $role ) ) {
2457 return Response::error( sprintf( 'Role "%s" is not registered. Available roles: %s.', $role, implode( ', ', array_keys( wp_roles()->roles ) ) ) );
2458 }
2459
2460 switch ( $action ) {
2461 case 'add':
2462 $user->add_role( $role );
2463 break;
2464 case 'remove':
2465 $user->remove_role( $role );
2466 break;
2467 case 'set':
2468 $user->set_role( $role );
2469 break;
2470 default:
2471 return Response::error( sprintf( 'Unsupported role action: "%s".', $action ) );
2472 }
2473
2474 // Refresh user object for the response.
2475 $refreshed = get_user_by( 'id', $user->ID );
2476 return Response::success(
2477 array(
2478 'ID' => (int) $user->ID,
2479 'login' => $user->user_login,
2480 'roles' => $refreshed instanceof \WP_User ? array_values( $refreshed->roles ) : array(),
2481 'action' => $action,
2482 'role' => $role,
2483 )
2484 );
2485 }
2486
2487 /**
2488 * Handle `user add-cap|remove-cap <id-or-login> <capability>`.
2489 *
2490 * Admin-class capabilities are refused in `verify_command_security()`.
2491 *
2492 * @param string $action 'add' | 'remove'.
2493 * @param string[] $positional [id-or-login, capability].
2494 * @return array<string,mixed>
2495 */
2496 private function handle_user_cap_change( string $action, array $positional ): array {
2497 $ident = $positional[0] ?? '';
2498 $cap = strtolower( $positional[1] ?? '' );
2499 if ( '' === $ident || '' === $cap ) {
2500 return Response::error( sprintf( 'Usage: user %s-cap <id-or-login> <capability>', $action ) );
2501 }
2502 $user = is_numeric( $ident ) ? get_user_by( 'id', (int) $ident ) : get_user_by( 'login', $ident );
2503 if ( ! $user ) {
2504 return Response::error( sprintf( 'User "%s" not found.', $ident ) );
2505 }
2506
2507 if ( 'add' === $action ) {
2508 $user->add_cap( $cap );
2509 } elseif ( 'remove' === $action ) {
2510 $user->remove_cap( $cap );
2511 } else {
2512 return Response::error( sprintf( 'Unsupported cap action: "%s".', $action ) );
2513 }
2514
2515 return Response::success(
2516 array(
2517 'ID' => (int) $user->ID,
2518 'login' => $user->user_login,
2519 'capability' => $cap,
2520 'action' => $action,
2521 'has_now' => user_can( $user->ID, $cap ),
2522 )
2523 );
2524 }
2525
2526 // ─── Menu handlers ──────────────────────────────────────────────────────────
2527
2528 /**
2529 * Handle "menu list".
2530 *
2531 * @param array<string,mixed> $flags Parsed flags.
2532 * @return array<string,mixed>
2533 */
2534 private function handle_menu_list( array $flags ): array {
2535 unset( $flags );
2536 $menus = wp_get_nav_menus();
2537 $out = array();
2538 foreach ( $menus as $m ) {
2539 $out[] = array(
2540 'term_id' => (int) $m->term_id,
2541 'name' => $m->name,
2542 'slug' => $m->slug,
2543 'count' => (int) $m->count,
2544 );
2545 }
2546 return Response::success( $out );
2547 }
2548
2549 /**
2550 * Handle "menu create".
2551 *
2552 * @param string[] $positional Remaining positional args.
2553 * @return array<string,mixed>
2554 */
2555 private function handle_menu_create( array $positional ): array {
2556 $name = $positional[0] ?? '';
2557 if ( '' === $name ) {
2558 return Response::error( 'Usage: menu create <name>' );
2559 }
2560 $id = wp_create_nav_menu( $name );
2561 if ( is_wp_error( $id ) ) {
2562 return Response::error( $id->get_error_message() );
2563 }
2564 return Response::success( array( 'term_id' => (int) $id ) );
2565 }
2566
2567 /**
2568 * Route "menu item ..." subcommands.
2569 *
2570 * @param string[] $positional Remaining positional args after "menu item".
2571 * @param array<string,mixed> $flags Parsed flags.
2572 * @return array<string,mixed>
2573 */
2574 private function route_menu_item( array $positional, array $flags ): array {
2575 $verb = strtolower( $positional[0] ?? '' );
2576
2577 switch ( $verb ) {
2578 case 'list':
2579 $menu = $positional[1] ?? '';
2580 if ( '' === $menu ) {
2581 return Response::error( 'Usage: menu item list <menu>' );
2582 }
2583 $items = wp_get_nav_menu_items( $menu );
2584 if ( false === $items ) {
2585 return Response::error( sprintf( 'Menu "%s" not found.', $menu ) );
2586 }
2587 $out = array();
2588 foreach ( $items as $item ) {
2589 if ( ! is_object( $item ) ) {
2590 continue;
2591 }
2592 /**
2593 * Narrowed type for `$item`.
2594 *
2595 * @var object{db_id:int,type:string,object:string,object_id:int,title:string,url:string,menu_item_parent:int} $item
2596 */
2597 $out[] = array(
2598 'db_id' => Utils::to_int( $item->db_id ),
2599 'type' => $item->type,
2600 'object' => $item->object,
2601 'object_id' => Utils::to_int( $item->object_id ),
2602 'title' => $item->title,
2603 'url' => $item->url,
2604 'parent' => Utils::to_int( $item->menu_item_parent ),
2605 );
2606 }
2607 return Response::success( $out );
2608
2609 case 'add-post':
2610 case 'add_post':
2611 $menu_id = isset( $positional[1] ) ? (int) $positional[1] : 0;
2612 $post_id = isset( $positional[2] ) ? (int) $positional[2] : 0;
2613 if ( $menu_id <= 0 || $post_id <= 0 ) {
2614 return Response::error( 'Usage: menu item add-post <menu-id> <post-id>' );
2615 }
2616 $post = get_post( $post_id );
2617 if ( ! $post ) {
2618 return Response::error( sprintf( 'Post %d not found.', $post_id ) );
2619 }
2620 $item_id = wp_update_nav_menu_item(
2621 $menu_id,
2622 0,
2623 array(
2624 'menu-item-title' => $post->post_title,
2625 'menu-item-object' => $post->post_type,
2626 'menu-item-object-id' => $post_id,
2627 'menu-item-type' => 'post_type',
2628 'menu-item-status' => 'publish',
2629 'menu-item-parent-id' => isset( $flags['parent-id'] ) ? Utils::to_int( $flags['parent-id'] ) : 0,
2630 )
2631 );
2632 if ( is_wp_error( $item_id ) ) {
2633 return Response::error( $item_id->get_error_message() );
2634 }
2635 return Response::success( array( 'menu_item_id' => (int) $item_id ) );
2636 }
2637
2638 return Response::error( sprintf( 'Unknown "menu item" subcommand: "%s"', $verb ) );
2639 }
2640
2641 // ─── Sidebar / widget / cron handlers ───────────────────────────────────────
2642
2643 /**
2644 * Handle "sidebar list".
2645 *
2646 * @return array<string,mixed>
2647 */
2648 private function handle_sidebar_list(): array {
2649 global $wp_registered_sidebars;
2650 $out = array();
2651 foreach ( (array) $wp_registered_sidebars as $id => $sidebar ) {
2652 if ( ! is_array( $sidebar ) ) {
2653 continue;
2654 }
2655 $out[] = array(
2656 'id' => $id,
2657 'name' => $sidebar['name'] ?? '',
2658 'description' => $sidebar['description'] ?? '',
2659 );
2660 }
2661 return Response::success( $out );
2662 }
2663
2664 /**
2665 * Handle "widget list <sidebar-id>".
2666 *
2667 * @param string[] $positional Remaining positional args.
2668 * @return array<string,mixed>
2669 */
2670 private function handle_widget_list( array $positional ): array {
2671 $sidebar_id = $positional[0] ?? '';
2672 if ( '' === $sidebar_id ) {
2673 return Response::error( 'Usage: widget list <sidebar-id>' );
2674 }
2675 // `wp_get_sidebars_widgets()` is internal/private in WP core and is
2676 // blocked by WPCS. Read the `sidebars_widgets` option directly — it
2677 // carries the same sidebar→widgets map.
2678 $map = get_option( 'sidebars_widgets', array() );
2679 if ( ! is_array( $map ) ) {
2680 $map = array();
2681 }
2682 $widgets = isset( $map[ $sidebar_id ] ) && is_array( $map[ $sidebar_id ] ) ? $map[ $sidebar_id ] : array();
2683 return Response::success( array_values( $widgets ) );
2684 }
2685
2686 /**
2687 * Handle "cron event list".
2688 *
2689 * @return array<string,mixed>
2690 */
2691 private function handle_cron_event_list(): array {
2692 $crons = _get_cron_array();
2693 $out = array();
2694 foreach ( $crons as $timestamp => $hooks ) {
2695 foreach ( (array) $hooks as $hook => $dings ) {
2696 foreach ( (array) $dings as $sig => $data ) {
2697 if ( ! is_array( $data ) ) {
2698 continue;
2699 }
2700 $out[] = array(
2701 'hook' => $hook,
2702 'next_run' => (int) $timestamp,
2703 'schedule' => $data['schedule'] ?? false,
2704 'interval' => $data['interval'] ?? null,
2705 'sig' => $sig,
2706 'args_count' => is_array( $data['args'] ?? null ) ? count( $data['args'] ) : 0,
2707 );
2708 }
2709 }
2710 }
2711 return Response::success( $out );
2712 }
2713
2714 // ─── Term handlers ──────────────────────────────────────────────────────────
2715
2716 /**
2717 * Handle "term list <taxonomy>".
2718 *
2719 * @param string[] $positional Remaining positional args (taxonomy at [0]).
2720 * @param array<string,mixed> $flags Parsed flags.
2721 * @return array<string,mixed>
2722 */
2723 private function handle_term_list( array $positional, array $flags ): array {
2724 $taxonomy = Utils::to_str( $positional[0] ?? $flags['taxonomy'] ?? '' );
2725 if ( '' === $taxonomy ) {
2726 return Response::error( 'Usage: term list <taxonomy> [--number=…] [--hide_empty=true|false]' );
2727 }
2728 if ( ! taxonomy_exists( $taxonomy ) ) {
2729 return Response::error( sprintf( 'Taxonomy "%s" is not registered.', $taxonomy ) );
2730 }
2731
2732 $hide_empty = isset( $flags['hide_empty'] )
2733 ? filter_var( $flags['hide_empty'], FILTER_VALIDATE_BOOLEAN )
2734 : false;
2735
2736 $filter_args = array(
2737 'taxonomy' => $taxonomy,
2738 'hide_empty' => $hide_empty,
2739 );
2740 if ( isset( $flags['parent'] ) ) {
2741 $filter_args['parent'] = Utils::to_int( $flags['parent'] );
2742 }
2743
2744 // Total matching terms across every page — used for `--format=count`
2745 // and the pagination envelope.
2746 $count = wp_count_terms( $filter_args );
2747 $total = is_wp_error( $count ) ? 0 : (int) $count;
2748 if ( $this->wants_count( $flags ) ) {
2749 return Response::success( $total );
2750 }
2751
2752 // Terms return ALL matches unless a page size is given (mirrors
2753 // `wp term list`); `--per_page=-1` is the explicit "all" idiom. When a
2754 // positive size is given, page deterministically. per_page === 0 means
2755 // "no limit" for get_terms.
2756 $size_raw = $flags['number'] ?? $flags['per_page'] ?? null;
2757 if ( null === $size_raw || Utils::to_int( $size_raw ) < 0 ) {
2758 $per_page = 0;
2759 } else {
2760 $per_page = max( 1, min( Utils::to_int( $size_raw ), 1000 ) );
2761 }
2762 if ( isset( $flags['offset'] ) ) {
2763 $offset = max( 0, Utils::to_int( $flags['offset'] ) );
2764 } elseif ( $per_page && isset( $flags['page'] ) ) {
2765 $offset = ( max( 1, Utils::to_int( $flags['page'] ) ) - 1 ) * $per_page;
2766 } else {
2767 $offset = 0;
2768 }
2769
2770 $query_args = $filter_args;
2771 if ( $per_page ) {
2772 $query_args['number'] = $per_page;
2773 $query_args['offset'] = $offset;
2774 }
2775
2776 // `--format=ids`: flat list of term IDs.
2777 if ( $this->wants_ids( $flags ) ) {
2778 $ids = get_terms( array_merge( $query_args, array( 'fields' => 'ids' ) ) );
2779 if ( is_wp_error( $ids ) ) {
2780 return Response::error( $ids->get_error_message() );
2781 }
2782 $ids = array_map( 'intval', $ids );
2783 return $this->paginated_response( $ids, $total, $per_page ? $per_page : max( 1, count( $ids ) ), $offset );
2784 }
2785
2786 $terms = get_terms( $query_args );
2787 if ( is_wp_error( $terms ) ) {
2788 return Response::error( $terms->get_error_message() );
2789 }
2790
2791 $out = array();
2792 foreach ( $terms as $term ) {
2793 $out[] = array(
2794 'term_id' => (int) $term->term_id,
2795 'name' => $term->name,
2796 'slug' => $term->slug,
2797 'count' => (int) $term->count,
2798 'taxonomy' => $term->taxonomy,
2799 'description' => $term->description,
2800 'parent' => (int) $term->parent,
2801 );
2802 }
2803 return $this->paginated_response( $out, $total, $per_page ? $per_page : max( 1, count( $out ) ), $offset );
2804 }
2805
2806 // ─── Schema discovery handlers (post-type / taxonomy list) ──────────────────
2807
2808 /**
2809 * Handle `post-type list [--public=true|false] [--hierarchical=true|false]
2810 * [--show_in_menu=…] [--show_in_rest=…] [--format=…]`.
2811 *
2812 * Mirrors WP-CLI's `wp post-type list` — returns one row per registered
2813 * post type with the fields the LLM most commonly needs to decide
2814 * downstream operations (which post type to query, whether it's REST-
2815 * exposed, what its rest_base is for /wp/v2/<base>).
2816 *
2817 * @param array<string,mixed> $flags Parsed flags.
2818 * @return array<string,mixed>
2819 */
2820 private function handle_post_type_list( array $flags ): array {
2821 $query = array();
2822 $bool_filters = array( 'public', 'hierarchical', 'show_in_menu', 'show_in_rest', 'show_ui', 'has_archive' );
2823 foreach ( $bool_filters as $key ) {
2824 if ( isset( $flags[ $key ] ) ) {
2825 $query[ $key ] = filter_var( $flags[ $key ], FILTER_VALIDATE_BOOLEAN );
2826 }
2827 }
2828
2829 $types = get_post_types( $query, 'objects' );
2830
2831 $out = array();
2832 foreach ( $types as $type ) {
2833 /**
2834 * Narrowed type for `$cap_type`.
2835 *
2836 * @var string|array<int,string> $cap_type
2837 */
2838 $cap_type = $type->capability_type;
2839 $out[] = array(
2840 'name' => $type->name,
2841 'label' => $type->label,
2842 'description' => $type->description,
2843 'public' => (bool) $type->public,
2844 'hierarchical' => (bool) $type->hierarchical,
2845 'show_in_menu' => is_bool( $type->show_in_menu ) ? $type->show_in_menu : (bool) $type->show_in_menu,
2846 'show_in_rest' => (bool) $type->show_in_rest,
2847 'rest_base' => $type->rest_base ? $type->rest_base : $type->name,
2848 'has_archive' => is_bool( $type->has_archive ) ? $type->has_archive : (bool) $type->has_archive,
2849 'capability_type' => is_array( $cap_type ) ? implode( ',', $cap_type ) : (string) $cap_type,
2850 '_builtin' => (bool) $type->_builtin,
2851 );
2852 }
2853 return Response::success( $out );
2854 }
2855
2856 /**
2857 * Handle `taxonomy list [--public=true|false] [--hierarchical=…]
2858 * [--object_type=<post_type>] [--format=…]`.
2859 *
2860 * Mirrors WP-CLI's `wp taxonomy list`.
2861 *
2862 * @param array<string,mixed> $flags Parsed flags.
2863 * @return array<string,mixed>
2864 */
2865 private function handle_taxonomy_list( array $flags ): array {
2866 $query = array();
2867 $bool_filters = array( 'public', 'hierarchical', 'show_in_menu', 'show_in_rest', 'show_ui' );
2868 foreach ( $bool_filters as $key ) {
2869 if ( isset( $flags[ $key ] ) ) {
2870 $query[ $key ] = filter_var( $flags[ $key ], FILTER_VALIDATE_BOOLEAN );
2871 }
2872 }
2873
2874 $object_type_filter = isset( $flags['object_type'] ) ? Utils::to_str( $flags['object_type'] ) : '';
2875
2876 $taxonomies = get_taxonomies( $query, 'objects' );
2877
2878 $out = array();
2879 foreach ( $taxonomies as $tax ) {
2880 // --object_type filters down to taxonomies attached to that post type.
2881 if ( '' !== $object_type_filter && ! in_array( $object_type_filter, $tax->object_type, true ) ) {
2882 continue;
2883 }
2884 $out[] = array(
2885 'name' => $tax->name,
2886 'label' => $tax->label,
2887 'description' => $tax->description,
2888 'public' => (bool) $tax->public,
2889 'hierarchical' => (bool) $tax->hierarchical,
2890 'object_type' => array_values( $tax->object_type ),
2891 'show_in_rest' => (bool) $tax->show_in_rest,
2892 'rest_base' => $tax->rest_base ? $tax->rest_base : $tax->name,
2893 '_builtin' => (bool) $tax->_builtin,
2894 );
2895 }
2896 return Response::success( $out );
2897 }
2898
2899 // ─── Diagnostics: role / db / rewrite / cron / env ──────────────────────────
2900
2901 /**
2902 * Handle `role list [--fields=role,name,capabilities] [--format=json]`.
2903 *
2904 * Mirrors `wp role list`. Returns one row per registered role with a
2905 * count of granted capabilities and a `builtin` flag for the WP core
2906 * roles. Use `role list-caps <role>` for the full cap set.
2907 *
2908 * @param array<string,mixed> $flags Parsed flags (unused except format passthrough).
2909 * @return array<string,mixed>
2910 */
2911 private function handle_role_list( array $flags ): array {
2912 unset( $flags );
2913 $roles_obj = wp_roles();
2914 $builtin = array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' );
2915 $out = array();
2916 foreach ( $roles_obj->roles as $slug => $info ) {
2917 $caps_array = isset( $info['capabilities'] ) && is_array( $info['capabilities'] ) ? $info['capabilities'] : array();
2918 // Only count caps explicitly granted (true). WordPress stores
2919 // removed caps as `false`, which `array_filter` strips out.
2920 $granted = array_filter( $caps_array );
2921 $out[] = array(
2922 'role' => $slug,
2923 'name' => isset( $info['name'] ) ? translate_user_role( Utils::to_str( $info['name'] ) ) : $slug,
2924 'capabilities' => count( $granted ),
2925 'builtin' => in_array( $slug, $builtin, true ),
2926 );
2927 }
2928 return Response::success( $out );
2929 }
2930
2931 /**
2932 * Handle `role list-caps <role>`.
2933 *
2934 * Returns the capability set explicitly granted to a role. Capabilities
2935 * stored as `false` (explicit removals) are omitted from the list to
2936 * match WP-CLI's `wp role list-caps` behaviour.
2937 *
2938 * @param string[] $positional Remaining tokens after `role list-caps`.
2939 * @return array<string,mixed>
2940 */
2941 private function handle_role_list_caps( array $positional ): array {
2942 $slug = strtolower( $positional[0] ?? '' );
2943 if ( '' === $slug ) {
2944 return Response::error( 'Usage: role list-caps <role>' );
2945 }
2946 $role = get_role( $slug );
2947 if ( ! $role instanceof \WP_Role ) {
2948 return Response::error( sprintf( 'Role "%s" is not registered. Use "role list" to see available roles.', $slug ) );
2949 }
2950 $granted = array_keys( array_filter( $role->capabilities ) );
2951 sort( $granted );
2952 return Response::success(
2953 array(
2954 'role' => $slug,
2955 'capabilities' => $granted,
2956 'count' => count( $granted ),
2957 )
2958 );
2959 }
2960
2961 /**
2962 * Handle `db size [--tables] [--human-readable]`.
2963 *
2964 * Read-only summary built from SHOW TABLE STATUS. Honors the wpdb
2965 * prefix so multisite blogs only count their own tables when invoked
2966 * inside a subsite context. Returns total size + optional per-table
2967 * breakdown when --tables is set.
2968 *
2969 * No arbitrary SQL is exposed; the verifier's `db query/import/...`
2970 * block remains in effect. This is a bounded read.
2971 *
2972 * @param array<string,mixed> $flags Parsed flags.
2973 * @return array<string,mixed>
2974 */
2975 private function handle_db_size( array $flags ): array {
2976 global $wpdb;
2977 /**
2978 * Narrowed type for `$wpdb`.
2979 *
2980 * @var \wpdb $wpdb
2981 */
2982 $show_tables = ! empty( $flags['tables'] );
2983 $human = ! empty( $flags['human-readable'] ) || ! empty( $flags['human_readable'] );
2984 $prefix_like = $wpdb->esc_like( $wpdb->prefix ) . '%';
2985
2986 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2987 $rows = $wpdb->get_results( $wpdb->prepare( 'SHOW TABLE STATUS LIKE %s', $prefix_like ), ARRAY_A );
2988 if ( ! is_array( $rows ) ) {
2989 return Response::error( 'Failed to read table status from MySQL.' );
2990 }
2991
2992 $total = 0;
2993 $tables_out = array();
2994 foreach ( $rows as $row ) {
2995 $data_len = isset( $row['Data_length'] ) ? Utils::to_int( $row['Data_length'] ) : 0;
2996 $index_len = isset( $row['Index_length'] ) ? Utils::to_int( $row['Index_length'] ) : 0;
2997 $size = $data_len + $index_len;
2998 $total += $size;
2999 if ( $show_tables ) {
3000 $entry = array(
3001 'name' => $row['Name'] ?? '',
3002 'rows' => isset( $row['Rows'] ) ? Utils::to_int( $row['Rows'] ) : null,
3003 'bytes' => $size,
3004 );
3005 if ( $human ) {
3006 $entry['size'] = size_format( $size, 2 );
3007 }
3008 $tables_out[] = $entry;
3009 }
3010 }
3011
3012 if ( $show_tables ) {
3013 // Sort descending by bytes — largest tables first, which is
3014 // what someone debugging size issues actually wants to see.
3015 usort(
3016 $tables_out,
3017 static function ( $a, $b ) {
3018 return $b['bytes'] - $a['bytes'];
3019 }
3020 );
3021 }
3022
3023 $payload = array(
3024 'total_bytes' => $total,
3025 );
3026 if ( $human ) {
3027 $payload['total_size'] = size_format( $total, 2 );
3028 }
3029 if ( $show_tables ) {
3030 $payload['tables'] = $tables_out;
3031 }
3032 return Response::success( $payload );
3033 }
3034
3035 /**
3036 * Handle `rewrite list`.
3037 *
3038 * Returns the cached rewrite_rules option as an array of
3039 * { match, query } entries. Useful for debugging "why is /foo/ returning
3040 * 404?" or "what URL pattern resolves to which post/term?".
3041 *
3042 * @return array<string,mixed>
3043 */
3044 private function handle_rewrite_list(): array {
3045 $rules = get_option( 'rewrite_rules' );
3046 if ( ! is_array( $rules ) || empty( $rules ) ) {
3047 return Response::success(
3048 array(
3049 'count' => 0,
3050 'rules' => array(),
3051 'note' => 'No rewrite rules cached. Try `rewrite flush` to regenerate them.',
3052 )
3053 );
3054 }
3055 $out = array();
3056 foreach ( $rules as $match => $query ) {
3057 $out[] = array(
3058 'match' => (string) $match,
3059 'query' => Utils::to_str( $query ),
3060 );
3061 }
3062 return Response::success(
3063 array(
3064 'count' => count( $out ),
3065 'rules' => $out,
3066 )
3067 );
3068 }
3069
3070 /**
3071 * Handle `cron schedule list`.
3072 *
3073 * Returns every registered cron interval (`hourly`, `daily`, `twicedaily`,
3074 * `weekly`, plus any custom schedules registered via the
3075 * `cron_schedules` filter). Used when scheduling a new cron event needs
3076 * to pick a valid recurrence.
3077 *
3078 * @return array<string,mixed>
3079 */
3080 private function handle_cron_schedule_list(): array {
3081 $schedules = wp_get_schedules();
3082 $out = array();
3083 foreach ( $schedules as $slug => $info ) {
3084 $out[] = array(
3085 'name' => (string) $slug,
3086 'display' => (string) $info['display'],
3087 'interval' => (int) $info['interval'],
3088 );
3089 }
3090 usort(
3091 $out,
3092 static function ( $a, $b ) {
3093 return $a['interval'] - $b['interval'];
3094 }
3095 );
3096 return Response::success( $out );
3097 }
3098
3099 /**
3100 * Handle `env`.
3101 *
3102 * Synthesised environment / diagnostic report covering the questions
3103 * users ask most often during troubleshooting: which PHP / MySQL /
3104 * WordPress are running, where is the install, multisite status,
3105 * debug flags, file-mod gates, memory limits, active locale.
3106 *
3107 * Read-only. Does NOT expose secrets (DB password, auth keys, etc.).
3108 *
3109 * @return array<string,mixed>
3110 */
3111 private function handle_env(): array {
3112 global $wpdb;
3113 /**
3114 * Narrowed type for `$wpdb`.
3115 *
3116 * @var \wpdb $wpdb
3117 */
3118 $mysql_version = '';
3119 if ( isset( $wpdb->dbh ) ) {
3120 // $wpdb exposes a public helper since WP 5.1.
3121 $mysql_version = (string) $wpdb->db_version();
3122 }
3123
3124 return Response::success(
3125 array(
3126 'wp' => array(
3127 'version' => get_bloginfo( 'version' ),
3128 'site_url' => get_option( 'siteurl' ),
3129 'home_url' => get_option( 'home' ),
3130 'abspath' => defined( 'ABSPATH' ) ? ABSPATH : '',
3131 'language' => get_locale(),
3132 'multisite' => is_multisite(),
3133 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG,
3134 'debug_log' => defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG,
3135 'script_debug' => defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG,
3136 'disallow_file_mods' => ! wp_is_file_mod_allowed( 'zipai_env_report' ),
3137 'disallow_file_edit' => defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT,
3138 'fs_method' => defined( 'FS_METHOD' ) ? Utils::to_str( constant( 'FS_METHOD' ) ) : '',
3139 'memory_limit' => defined( 'WP_MEMORY_LIMIT' ) ? WP_MEMORY_LIMIT : '',
3140 'max_memory' => defined( 'WP_MAX_MEMORY_LIMIT' ) ? WP_MAX_MEMORY_LIMIT : '',
3141 'table_prefix' => $wpdb->prefix,
3142 ),
3143 'php' => array(
3144 'version' => PHP_VERSION,
3145 'sapi' => PHP_SAPI,
3146 'memory_limit' => (string) ini_get( 'memory_limit' ),
3147 'max_execution_time' => (int) ini_get( 'max_execution_time' ),
3148 'upload_max_filesize' => (string) ini_get( 'upload_max_filesize' ),
3149 'post_max_size' => (string) ini_get( 'post_max_size' ),
3150 'extensions' => array(
3151 'curl' => extension_loaded( 'curl' ),
3152 'gd' => extension_loaded( 'gd' ),
3153 'imagick' => extension_loaded( 'imagick' ),
3154 'mbstring' => extension_loaded( 'mbstring' ),
3155 'mysqli' => extension_loaded( 'mysqli' ),
3156 'openssl' => extension_loaded( 'openssl' ),
3157 'sodium' => extension_loaded( 'sodium' ),
3158 'zip' => extension_loaded( 'zip' ),
3159 ),
3160 ),
3161 'mysql' => array(
3162 'version' => $mysql_version,
3163 ),
3164 'theme' => array(
3165 'stylesheet' => Utils::to_str( get_option( 'stylesheet' ) ),
3166 'template' => Utils::to_str( get_option( 'template' ) ),
3167 ),
3168 )
3169 );
3170 }
3171
3172 /**
3173 * Handle `core check-update [--format=json]`.
3174 *
3175 * Reports core updates WordPress already knows about, read from the
3176 * cached `update_core` site transient that core's own scheduled
3177 * `wp_version_check()` populates. It does NOT force a live wp.org
3178 * request: a web/REST request must stay fast and cannot block on an
3179 * outbound call, and WordPress's own Site Health screen reads the
3180 * same cached data. Returns an empty `updates` list when the install
3181 * is up to date (or when the cache hasn't been primed yet).
3182 *
3183 * Mirrors the useful half of `wp core check-update`.
3184 *
3185 * @return array<string,mixed>
3186 */
3187 private function handle_core_check_update(): array {
3188 if ( ! function_exists( 'get_core_updates' ) ) {
3189 require_once ABSPATH . 'wp-admin/includes/update.php';
3190 }
3191
3192 $current = get_bloginfo( 'version' );
3193 $updates = function_exists( 'get_core_updates' ) ? get_core_updates() : array();
3194 if ( ! is_array( $updates ) ) {
3195 $updates = array();
3196 }
3197
3198 $available = array();
3199 foreach ( $updates as $update ) {
3200 if ( ! is_object( $update ) ) {
3201 continue;
3202 }
3203 // `response === 'upgrade'` is the only state that offers a newer
3204 // core build; 'latest' / 'development' rows are not actionable.
3205 if ( ! isset( $update->response ) || 'upgrade' !== $update->response ) {
3206 continue;
3207 }
3208 $offered = isset( $update->current ) ? Utils::to_str( $update->current ) : '';
3209 if ( '' !== $offered && version_compare( $offered, $current, '<=' ) ) {
3210 continue;
3211 }
3212 $available[] = array(
3213 'version' => $offered,
3214 'locale' => isset( $update->locale ) ? Utils::to_str( $update->locale, 'en_US' ) : 'en_US',
3215 'package' => ( isset( $update->packages ) && is_object( $update->packages ) && isset( $update->packages->full ) ) ? Utils::to_str( $update->packages->full ) : '',
3216 'php_version' => isset( $update->php_version ) ? Utils::to_str( $update->php_version ) : '',
3217 'mysql_version' => isset( $update->mysql_version ) ? Utils::to_str( $update->mysql_version ) : '',
3218 );
3219 }
3220
3221 return Response::success(
3222 array(
3223 'current_version' => $current,
3224 'update_available' => ! empty( $available ),
3225 'updates' => $available,
3226 'note' => empty( $updates )
3227 ? 'Core update cache is empty — WordPress has not run its scheduled version check yet. "update_available: false" here means "no known update", not a guaranteed up-to-date result.'
3228 : '',
3229 )
3230 );
3231 }
3232
3233 /**
3234 * Handle `cli info`.
3235 *
3236 * There is no WP-CLI binary in web/REST context, so report the PHP and
3237 * WordPress runtime the request is actually executing under — the half
3238 * of `wp cli info` that is meaningful here. Keeps callers that probe
3239 * `cli info` for runtime facts (e.g. a Site Health snapshot) working
3240 * instead of erroring out on the default "not available" branch.
3241 *
3242 * @return array<string,mixed>
3243 */
3244 private function handle_cli_info(): array {
3245 return Response::success(
3246 array(
3247 'php_binary' => defined( 'PHP_BINARY' ) ? PHP_BINARY : '',
3248 'php_version' => PHP_VERSION,
3249 'php_sapi' => PHP_SAPI,
3250 'wp_version' => get_bloginfo( 'version' ),
3251 'wp_cli' => false,
3252 'context' => 'web-rest',
3253 'note' => 'Running in web/REST context — no WP-CLI runtime. Reported values reflect the PHP/WordPress process serving this request.',
3254 )
3255 );
3256 }
3257
3258 // ─── Search-replace handler ─────────────────────────────────────────────────
3259 // `handle_search_replace()` and its serialized-PHP walker live in
3260 // Search_Replace_Engine_Trait (search-replace-engine-trait.php).
3261
3262 // ─── Shared helpers ─────────────────────────────────────────────────────────
3263
3264 /**
3265 * Split parsed args into positional tokens and --flag map.
3266 *
3267 * Supports `--key=value`, `--key value`, and boolean `--key`.
3268 *
3269 * @param string[] $args Parsed command tokens.
3270 * @return array{0:string[],1:array<string,bool|string>}
3271 */
3272 private function parse_flags( array $args ): array {
3273 $positional = array();
3274 $flags = array();
3275 $pending_key = null;
3276 $value_flags = array(
3277 'status',
3278 'format',
3279 'fields',
3280 'post_type',
3281 'post_status',
3282 'posts_per_page',
3283 'per_page',
3284 'page',
3285 'offset',
3286 'orderby',
3287 'order',
3288 's',
3289 'search',
3290 'number',
3291 'role',
3292 'hide_empty',
3293 'expiration',
3294 'autoload',
3295 'reassign',
3296 'parent-id',
3297 'post_title',
3298 'post_content',
3299 'post_name',
3300 'post_excerpt',
3301 'post_author',
3302 'menu_order',
3303 'slug',
3304 'parent',
3305 'description',
3306 'name',
3307 'user_login',
3308 'user_email',
3309 'user_pass',
3310 'first_name',
3311 'last_name',
3312 'display_name',
3313 'limit',
3314 'skip-columns',
3315 'include-columns',
3316 'taxonomy',
3317 // comment list filters + create/update fields.
3318 'post_id',
3319 'type',
3320 'comment_post_ID',
3321 'comment_content',
3322 'comment_author',
3323 'comment_author_email',
3324 'comment_author_url',
3325 'comment_approved',
3326 'comment_parent',
3327 'comment_type',
3328 'comment_date',
3329 'user_id',
3330 // post-type / taxonomy schema-discovery filters.
3331 'public',
3332 'hierarchical',
3333 'show_in_menu',
3334 'show_in_rest',
3335 'show_ui',
3336 'has_archive',
3337 'object_type',
3338 );
3339
3340 foreach ( $args as $arg ) {
3341 if ( null !== $pending_key ) {
3342 $flags[ $pending_key ] = $arg;
3343 $pending_key = null;
3344 continue;
3345 }
3346 if ( str_starts_with( $arg, '--' ) ) {
3347 $body = substr( $arg, 2 );
3348 if ( str_contains( $body, '=' ) ) {
3349 list( $k, $v ) = explode( '=', $body, 2 );
3350 $flags[ $k ] = $v;
3351 } elseif ( in_array( $body, $value_flags, true ) ) {
3352 // Value follows in the next token.
3353 $pending_key = $body;
3354 } else {
3355 $flags[ $body ] = true;
3356 }
3357 continue;
3358 }
3359 $positional[] = $arg;
3360 }
3361
3362 if ( null !== $pending_key ) {
3363 // Trailing `--key` with no value — treat as boolean true.
3364 $flags[ $pending_key ] = true;
3365 }
3366
3367 return array( $positional, $flags );
3368 }
3369
3370 /**
3371 * Normalise the --fields flag.
3372 *
3373 * @param array<string,mixed> $flags Parsed flags.
3374 * @param string[]|null $default Default field set, or null for "all".
3375 * @return string[]|null
3376 */
3377 private function resolve_fields_flag( array $flags, ?array $default ): ?array {
3378 if ( empty( $flags['fields'] ) ) {
3379 return $default;
3380 }
3381 $raw = is_array( $flags['fields'] ) ? $flags['fields'] : explode( ',', Utils::to_str( $flags['fields'] ) );
3382 return array_values( array_filter( array_map( static fn ( $v ) => trim( is_scalar( $v ) ? (string) $v : '' ), $raw ), static fn ( string $s ): bool => '' !== $s ) );
3383 }
3384
3385 /**
3386 * Serialise a WP_Post to a JSON-friendly array.
3387 *
3388 * @param \WP_Post $post Post.
3389 * @param string[]|null $fields Field subset; null for all.
3390 * @return array<string,mixed>
3391 */
3392 private function post_to_array( \WP_Post $post, ?array $fields ): array {
3393 $all = array(
3394 'ID' => (int) $post->ID,
3395 'post_title' => $post->post_title,
3396 'post_status' => $post->post_status,
3397 'post_type' => $post->post_type,
3398 'post_date' => $post->post_date,
3399 'post_modified' => $post->post_modified,
3400 'post_author' => (int) $post->post_author,
3401 'post_parent' => (int) $post->post_parent,
3402 'post_name' => $post->post_name,
3403 'post_excerpt' => $post->post_excerpt,
3404 'menu_order' => (int) $post->menu_order,
3405 'comment_status' => $post->comment_status,
3406 'ping_status' => $post->ping_status,
3407 'guid' => $post->guid,
3408 );
3409 if ( null === $fields ) {
3410 return $all;
3411 }
3412 $out = array();
3413 foreach ( $fields as $f ) {
3414 $out[ $f ] = $all[ $f ] ?? null;
3415 }
3416 return $out;
3417 }
3418
3419 /**
3420 * Serialise a WP_User to a JSON-friendly array.
3421 *
3422 * @param \WP_User $user User.
3423 * @param string[]|null $fields Field subset; null for all safe fields.
3424 * @return array<string,mixed>
3425 */
3426 private function user_to_array( \WP_User $user, ?array $fields ): array {
3427 $all = array(
3428 'ID' => (int) $user->ID,
3429 'user_login' => $user->user_login,
3430 'user_email' => $user->user_email,
3431 'user_nicename' => $user->user_nicename,
3432 'display_name' => $user->display_name,
3433 'user_registered' => $user->user_registered,
3434 'roles' => $user->roles,
3435 );
3436 if ( null === $fields ) {
3437 return $all;
3438 }
3439 $out = array();
3440 foreach ( $fields as $f ) {
3441 $out[ $f ] = $all[ $f ] ?? null;
3442 }
3443 return $out;
3444 }
3445
3446 /**
3447 * Translate WP-CLI-style --flags into a wp_insert_post/wp_update_post array.
3448 * Skips --post_content (blocked by `verify_command_security`).
3449 *
3450 * @param array<string,mixed> $flags Parsed flags.
3451 * @return array{post_title?:string,post_status?:string,post_type?:string,post_name?:string,post_excerpt?:string,post_author?:int,post_parent?:int,menu_order?:int,comment_status?:string,ping_status?:string,post_date?:string,post_password?:string}
3452 */
3453 private function flags_to_post_data( array $flags ): array {
3454 $out = array();
3455 if ( array_key_exists( 'post_title', $flags ) ) {
3456 $out['post_title'] = Utils::to_str( $flags['post_title'] );
3457 }
3458 if ( array_key_exists( 'post_status', $flags ) ) {
3459 $out['post_status'] = Utils::to_str( $flags['post_status'] );
3460 }
3461 if ( array_key_exists( 'post_type', $flags ) ) {
3462 $out['post_type'] = Utils::to_str( $flags['post_type'] );
3463 }
3464 if ( array_key_exists( 'post_name', $flags ) ) {
3465 $out['post_name'] = Utils::to_str( $flags['post_name'] );
3466 }
3467 if ( array_key_exists( 'post_excerpt', $flags ) ) {
3468 $out['post_excerpt'] = Utils::to_str( $flags['post_excerpt'] );
3469 }
3470 if ( array_key_exists( 'post_author', $flags ) ) {
3471 $out['post_author'] = Utils::to_int( $flags['post_author'] );
3472 }
3473 if ( array_key_exists( 'post_parent', $flags ) ) {
3474 $out['post_parent'] = Utils::to_int( $flags['post_parent'] );
3475 }
3476 if ( array_key_exists( 'menu_order', $flags ) ) {
3477 $out['menu_order'] = Utils::to_int( $flags['menu_order'] );
3478 }
3479 if ( array_key_exists( 'comment_status', $flags ) ) {
3480 $out['comment_status'] = Utils::to_str( $flags['comment_status'] );
3481 }
3482 if ( array_key_exists( 'ping_status', $flags ) ) {
3483 $out['ping_status'] = Utils::to_str( $flags['ping_status'] );
3484 }
3485 if ( array_key_exists( 'post_date', $flags ) ) {
3486 $out['post_date'] = Utils::to_str( $flags['post_date'] );
3487 }
3488 if ( array_key_exists( 'post_password', $flags ) ) {
3489 $out['post_password'] = Utils::to_str( $flags['post_password'] );
3490 }
3491 return $out;
3492 }
3493
3494 // ─── Command parsing ────────────────────────────────────────────────────────
3495 // `parse_command_to_args()` and `parse_output()` live in
3496 // Command_Parser_Trait (command-parser-trait.php).
3497 }
3498