PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / trunk
ZIP AI – AI Website Builder & AI Agent (Beta) vtrunk
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 (Beta) trunk, at inc/abilities/core/run-wp-cli.php

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