Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.50% covered (success)
95.50%
530 / 555
18.18% covered (danger)
18.18%
2 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockEditor
95.50% covered (success)
95.50%
530 / 555
18.18% covered (danger)
18.18%
2 / 11
58
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_abilities
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 register_edit_block
94.67% covered (success)
94.67%
71 / 75
0.00% covered (danger)
0.00%
0 / 1
7.01
 register_add_section
97.89% covered (success)
97.89%
93 / 95
0.00% covered (danger)
0.00%
0 / 1
16
 register_delete_block
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
2
 register_duplicate
89.29% covered (warning)
89.29%
75 / 84
0.00% covered (danger)
0.00%
0 / 1
9.10
 register_insert_inner_block
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
5
 register_move_block
93.85% covered (success)
93.85%
61 / 65
0.00% covered (danger)
0.00%
0 / 1
10.02
 register_highlight_block
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
2
 register_update_block_attrs
97.87% covered (success)
97.87%
46 / 47
0.00% covered (danger)
0.00%
0 / 1
3
 register_get_block_markup
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Block Editor Abilities
4 *
5 * Provides abilities for editing WordPress block content in the site editor.
6 * These abilities return action data that the client executes via blockEditorService.js.
7 *
8 * @package BLU
9 */
10
11declare( strict_types=1 );
12
13namespace BLU\Abilities;
14
15/**
16 * BlockEditor class
17 *
18 * Registers abilities for editing, adding, deleting, and moving blocks.
19 * Each ability is a single-purpose tool designed for clear AI tool selection.
20 */
21class BlockEditor {
22
23    /**
24     * Constructor - registers block editor abilities.
25     */
26    public function __construct() {
27        $this->register_abilities();
28    }
29
30    /**
31     * Register all block editor abilities.
32     *
33     * @return void
34     */
35    private function register_abilities(): void {
36        $this->register_edit_block();
37        $this->register_add_section();
38        $this->register_delete_block();
39        $this->register_duplicate();
40        $this->register_insert_inner_block();
41        $this->register_move_block();
42        $this->register_get_block_markup();
43        $this->register_highlight_block();
44        $this->register_update_block_attrs();
45    }
46
47    /**
48     * Register ability to edit block content (rewrite mode only)
49     *
50     * Replaces the entire content of a block with new WordPress block markup.
51     *
52     * @return void
53     */
54    private function register_edit_block(): void {
55        // phpcs:disable Generic.Files.LineLength.TooLong -- Tool description includes inline rules for AI context.
56        $description = <<<'DESC'
57        LAST RESORT â€” prefer a cheaper tool first. This tool forces you to re-emit the block's ENTIRE subtree markup (all inner blocks included) and is by far the slowest operation, so pick one of the alternatives below whenever they fit:
58        - "add another <child> with the same design as these" â†’ blu/duplicate-block (clones an existing sibling, zero markup regeneration).
59        - "insert a <new child> into this container" (columns, group, row, stack) â†’ blu/insert-inner-block.
60        - "change color / spacing / padding / alignment / font / any attribute" on any block (top-level or nested) â†’ blu/update-block-attrs with the block's own client_id.
61        - "add a new top-level section on the page" â†’ blu/add-section.
62        - "delete / move / reorder" â†’ blu/delete-block or blu/move-block.
63
64        Use edit-block ONLY when the user asks for structural content rewrites that cannot be expressed as a duplicate, insert, attribute patch, add, delete, or move â€” for example replacing a paragraph's text, swapping an image, or rebuilding a block's inner markup from scratch.
65
66        Replace the entire content of an existing block (and its inner blocks) with new WordPress block markup. The block_content MUST be valid WordPress block markup with proper block comments (<!-- wp:blockname {...} -->...<!-- /wp:blockname -->). Always include all inner blocks if the target block has children. Use the client_id from the block tree context. TEMPLATE PARTS: When editing a core/template-part, provide ONLY the inner blocks markup â€” do NOT wrap it in <!-- wp:template-part --> comments. You can wrap blocks in core/group, core/stack, or similar container blocks when needed for layout or styling.
67
68        ADDITIONAL RULES:
69        - CONTENT PRESERVATION (CRITICAL): Copy ALL existing text, link URLs, image sources, and inner blocks from the original markup into your replacement. NEVER substitute existing content with different or generic text. If the button says "Start Creating for Free Today!" and you are changing its color, your output MUST keep that exact text â€” changing it to "Contact Us", "Button", or anything else is a critical error. For style-only changes (color, font, spacing), modify ONLY the block comment JSON and the corresponding HTML classes/styles â€” copy everything else character-for-character.
70        - VALID MARKUP: Every block_content you provide MUST be valid WordPress block markup with proper <!-- wp:name {attrs} --> comments. Never output plain HTML without block comments.
71        - INNER BLOCKS: When editing a block that has inner blocks, include ALL inner blocks in your replacement markup unless the user specifically asked to remove them.
72        - CONTAINER BLOCKS: Container blocks (core/group, core/columns, core/column, core/cover, core/row, core/stack, core/buttons) render content using WordPress InnerBlocks. ALL visible content inside them MUST be wrapped in proper block comments. NEVER put raw HTML like <p>, <h2>, <ul>, or <figure> directly inside a container wrapper â€” it will be silently stripped and the block will appear empty. WRONG: <!-- wp:group --> <div class="wp-block-group"><p>Hello</p></div> <!-- /wp:group -->. RIGHT: <!-- wp:group --> <div class="wp-block-group"><!-- wp:paragraph --> <p>Hello</p> <!-- /wp:paragraph --></div> <!-- /wp:group -->.
73        - COLORS: ALWAYS use the site's theme palette colors â€” NEVER invent arbitrary hex colors (like yellow #ffcc00 or blue #0066cc). The available palette slugs are: base, contrast, accent-1, accent-2, accent-3, accent-4, accent-5, accent-6. Apply them via "backgroundColor" and "textColor" attributes (e.g., "backgroundColor":"accent-1", "textColor":"contrast"). Only use custom hex values when the user explicitly requests a specific color. To reference a palette color in the style object use "var:preset|color|<slug>". In inline CSS use var(--wp--preset--color--<slug>). In HTML, use has-<slug>-background-color / has-<slug>-color classes. If the existing markup has an invalid slug (e.g., "backgroundColor":"red"), fix it by replacing with the nearest palette slug or removing the attribute and using the style object with a HEX value: {"style":{"color":{"background":"#ff0000"}}}. This rule applies to EVERY block in your output â€” scan the ENTIRE block_content before returning it.
74        - NFD UTILITY CLASSES: Do NOT add new nfd-* classes to blocks. When editing a block that has existing nfd-* classes, PRESERVE all nfd-* classes unless the user specifically asks to change the property they control. If the user asks to change a property controlled by an nfd-* class (e.g., "change the padding"), remove the nfd-* class for that property and apply the styling using WordPress block attributes instead. If the editor context includes an nfd class reference section, use it to understand what each class does. Key rules: NEVER remove nfd-container (controls container width), nfd-theme-* (controls color scheme), nfd-wb-*/nfd-delay-* (controls animations), nfd-bg-effect-* (controls decorative backgrounds), nfd-divider-* (controls section dividers). When replacing an nfd-* spacing/color/typography class, use the resolved CSS value from the reference (not a guess) to set the equivalent WordPress block attribute. Preserve: nfd-bg-surface, nfd-bg-primary, nfd-bg-subtle, nfd-text-faded, nfd-text-contrast, nfd-text-primary, nfd-btn-*, nfd-rounded-*, nfd-shadow-*.
75        - IMAGE ASPECT RATIO: When the user asks to change an image's aspect ratio, use the "aspectRatio" and "scale" attributes â€” NEVER set fixed "width"/"height" in pixels. Valid aspect ratios: "1/1", "4/3", "3/4", "3/2", "2/3", "16/9", "9/16". Example: <!-- wp:image {"aspectRatio":"16/9","scale":"cover","sizeSlug":"full"} --> <figure class="wp-block-image size-full"><img src="..." alt="" style="aspect-ratio:16/9;object-fit:cover"/></figure> <!-- /wp:image -->. The inline style on the <img> tag MUST match: style="aspect-ratio:{ratio};object-fit:{scale}". Remove any existing "width" and "height" attributes and "is-resized" class when switching to aspect ratio.
76        - COVER BLOCK OVERLAY: The cover block overlay color is controlled ONLY through block comment attributes â€” NEVER add inline styles to the overlay <span>. The <span> must only have classes, no style attribute. For theme palette colors: use "overlayColor":"<slug>" in the block comment and add class has-<slug>-background-color to the span. For custom colors: use "customOverlayColor":"#hex" in the block comment. The span gets NO inline style â€” WordPress handles it. Overlay opacity is set via "dimRatio" (0-100) in the block comment. The span class reflects it: has-background-dim-{value} has-background-dim. WRONG: style="background-color:rgba(...)" on the span â€” this causes block validation failure.
77        - GRADIENTS: To add a gradient background to a block, use the style.color.gradient attribute in the block comment â€” NEVER put background-image in the inline style. Block comment: {"style":{"color":{"gradient":"linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%)"}}}. HTML: style="background:linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%)" (use background: not background-image:). Add has-background class. For theme presets use: {"gradient":"vivid-cyan-blue-to-vivid-purple"}. WRONG: {"style":{"elements":{"background":{"backgroundImage":"..."}}}} or style="background-image:linear-gradient(...)".
78        - FONT SIZE: When changing a block's font size, ALWAYS remove any existing font-size selection first â€” then apply the new one. Preset slugs and custom values are mutually exclusive; combining them causes the preset to silently win via CSS specificity. CUSTOM size: REMOVE "fontSize" attribute and has-*-font-size class, then set "style":{"typography":{"fontSize":"4.5rem"}} and style="font-size:4.5rem". PRESET size: REMOVE style.typography.fontSize and inline font-size, then set "fontSize":"x-large" and add has-x-large-font-size class. WRONG: {"fontSize":"x-large","style":{"typography":{"fontSize":"4.5rem"}}}.
79        - ALIGNMENT & CENTERING: The core/group block does NOT support the align attribute for centering â€” do NOT set "align":"center" on a group. For flex containers (core/columns, core/buttons): use "align":"center" directly. For core/row or core/stack: set "layout":{"type":"flex","justifyContent":"center"}. For content inside a group: set alignment on inner blocks â€” core/image and core/buttons support "align":"center"; core/heading and core/paragraph use "textAlign":"center". WRONG: <!-- wp:group {"align":"center"} -->.
80        - TEMPLATE PARTS: NEVER use edit-block on a template part for COLOR, STYLE, or ATTRIBUTE changes â€” it will lose blocks like the site-logo. Use blu/update-block-attrs on the SPECIFIC inner block instead. Only use edit-block on a template part to REPLACE ALL content with a completely new design. When ADDING content to a template part, use blu/add-section with before/after_client_id pointing INSIDE the template part.
81        - IMAGES: When replacing an image already on the page, rewrite its <img src="…"> to use a __IMG_1__ (or __IMG_2__, â€¦) placeholder and pass a descriptive prompt per placeholder in image_prompts (preferred â€” client generates and substitutes). Use image_urls only when you already have resolved URLs. NEVER embed a full image URL directly in multi-block block_content.
82        DESC;
83        // phpcs:enable Generic.Files.LineLength.TooLong
84
85        blu_register_ability(
86            'blu/edit-block',
87            array(
88                'label'               => 'Edit Block Content',
89                'description'         => $description,
90                'category'            => 'blu-mcp',
91                'input_schema'        => array(
92                    'type'       => 'object',
93                    'properties' => array(
94                        'client_id'     => array(
95                            'type'        => 'string',
96                            'description' => 'The clientId of the block to edit, from the block tree context',
97                        ),
98                        'block_content' => array(
99                            'type'        => 'string',
100                            'description' => 'Complete WordPress block markup with block comments. Must include <!-- wp:blockname {...} --> opening and <!-- /wp:blockname --> closing comments. Include all inner blocks if the target has children.',
101                        ),
102                        'image_prompts' => array(
103                            'type'        => 'array',
104                            'items'       => array(
105                                'oneOf' => array(
106                                    array( 'type' => 'string' ),
107                                    array(
108                                        'type'       => 'object',
109                                        'properties' => array(
110                                            'prompt'      => array( 'type' => 'string' ),
111                                            'orientation' => array( 'type' => 'string' ),
112                                            'width'       => array( 'type' => 'integer' ),
113                                            'height'      => array( 'type' => 'integer' ),
114                                        ),
115                                        'required'   => array( 'prompt' ),
116                                    ),
117                                ),
118                            ),
119                            'description' => 'Preferred image parameter. One entry per __IMG_N__ placeholder in block_content (in order). Each entry is either a string prompt or {prompt, orientation?, width?, height?}. The client calls blu/generate-image per entry and substitutes each placeholder with the returned URL.',
120                        ),
121                        'image_urls'    => array(
122                            'type'        => 'array',
123                            'items'       => array( 'type' => 'string' ),
124                            'description' => 'Fallback path when resolved image URLs are already available. Ignored if image_prompts is provided. Replaces __IMG_1__, __IMG_2__, â€¦ in order.',
125                        ),
126                    ),
127                    'required'   => array( 'client_id', 'block_content' ),
128                ),
129                'execute_callback'    => function ( $input ) {
130                    // Validate required fields
131                    if ( empty( $input['client_id'] ) ) {
132                        return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) );
133                    }
134                    if ( empty( $input['block_content'] ) ) {
135                        return blu_prepare_ability_response( 400, array( 'message' => 'block_content is required' ) );
136                    }
137
138                    // Return action data for client-side execution
139                    $response_data = array(
140                        'action'        => 'edit_block',
141                        'client_id'     => sanitize_text_field( $input['client_id'] ),
142                        'block_content' => $input['block_content'], // Don't sanitize - it's block HTML
143                        'message'       => 'Block edit ready for execution',
144                    );
145
146                    // Forward image inputs to the client for placeholder substitution.
147                    if ( ! empty( $input['image_prompts'] ) && is_array( $input['image_prompts'] ) ) {
148                        $response_data['image_prompts'] = $input['image_prompts'];
149                    }
150                    if ( ! empty( $input['image_urls'] ) && is_array( $input['image_urls'] ) ) {
151                        $response_data['image_urls'] = array_map( 'esc_url_raw', $input['image_urls'] );
152                    }
153
154                    return blu_prepare_ability_response( 200, $response_data );
155                },
156                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
157                'meta'                => array(
158                    'annotations' => array(
159                        'readonly'    => false,
160                        'destructive' => true,
161                        'idempotent'  => false,
162                    ),
163                    'mcp'         => array(
164                        'public' => true,
165                        'type'   => 'tool',
166                    ),
167                ),
168            )
169        );
170    }
171
172    /**
173     * Register ability to add a new section
174     *
175     * Inserts new block(s) at a specific position in the page.
176     *
177     * @return void
178     */
179    private function register_add_section(): void {
180        // phpcs:disable Generic.Files.LineLength.TooLong -- Tool description includes inline rules for AI context.
181        $description = <<<'DESC'
182        Insert new block content at a specific position in the page. Use after_client_id to insert after a block, before_client_id to insert before a block, or set after_client_id to null (and omit before_client_id) to insert at the very top of the page.
183
184        ADDITIONAL RULES:
185        - VALID MARKUP: Every block_content you provide MUST be valid WordPress block markup with proper <!-- wp:name {attrs} --> comments. Never output plain HTML without block comments.
186        - CONTAINER BLOCKS: Container blocks (core/group, core/columns, core/column, core/cover, core/row, core/stack, core/buttons) render content using WordPress InnerBlocks. ALL visible content inside them MUST be wrapped in proper block comments. NEVER put raw HTML like <p>, <h2>, <ul>, or <figure> directly inside a container wrapper â€” it will be silently stripped and the block will appear empty. WRONG: <!-- wp:group --> <div class="wp-block-group"><p>Hello</p></div> <!-- /wp:group -->. RIGHT: <!-- wp:group --> <div class="wp-block-group"><!-- wp:paragraph --> <p>Hello</p> <!-- /wp:paragraph --></div> <!-- /wp:group -->.
187        - ADDING SECTIONS: You can insert content before or after ANY block at any nesting depth â€” not just top-level blocks. Use after_client_id to insert after a specific block, or before_client_id to insert before it. When the user does NOT specify a position, insert at the top level of the page (use after_client_id of the last top-level block in the tree, or null for the very top).
188        - TEMPLATE PARTS: When adding content to a template part (e.g., adding a top bar above a header, or a banner inside a footer), use blu/add-section with before_client_id or after_client_id pointing to a block INSIDE the template part. This preserves all existing blocks and their layout. Do NOT rewrite the entire template part with blu/edit-block just to add new content â€” rewriting risks losing layout attributes (flex, gap, etc.) and breaking the design. Only use blu/edit-block on a template part when replacing ALL of its content with a completely different design.
189        - COLORS: Use the site's theme palette slugs (base, contrast, accent-1..6) via "backgroundColor"/"textColor". Only use custom hex when the user explicitly requests a specific color. In the style object use "var:preset|color|<slug>". In inline CSS use var(--wp--preset--color--<slug>). In HTML use has-<slug>-background-color / has-<slug>-color classes.
190        - IMAGES: Use __IMG_1__, __IMG_2__, â€¦ as URL placeholders in block_content and provide a descriptive prompt for each placeholder in image_prompts (preferred â€” the client generates and substitutes URLs automatically). Fall back to image_urls only when you already have resolved URLs on hand. NEVER embed full image URLs directly in multi-block block_content â€” it will be truncated.
191        DESC;
192        // phpcs:enable Generic.Files.LineLength.TooLong
193
194        blu_register_ability(
195            'blu/add-section',
196            array(
197                'label'               => 'Add New Section',
198                'description'         => $description,
199                'category'            => 'blu-mcp',
200                'input_schema'        => array(
201                    'type'       => 'object',
202                    'properties' => array(
203                        'after_client_id'  => array(
204                            'type'        => array( 'string', 'null' ),
205                            'description' => 'The clientId of the block to insert AFTER. Use null to insert at the very top of the page. Mutually exclusive with before_client_id.',
206                        ),
207                        'before_client_id' => array(
208                            'type'        => 'string',
209                            'description' => 'The clientId of the block to insert BEFORE. Use this when adding content above the first block inside a template part or at the start of a section. Mutually exclusive with after_client_id.',
210                        ),
211                        'block_content'    => array(
212                            'type'        => 'string',
213                            'description' => 'Complete WordPress block markup for the new section.',
214                        ),
215                        'image_prompts'    => array(
216                            'type'        => 'array',
217                            'items'       => array(
218                                'oneOf' => array(
219                                    array( 'type' => 'string' ),
220                                    array(
221                                        'type'       => 'object',
222                                        'properties' => array(
223                                            'prompt'      => array( 'type' => 'string' ),
224                                            'orientation' => array( 'type' => 'string' ),
225                                            'width'       => array( 'type' => 'integer' ),
226                                            'height'      => array( 'type' => 'integer' ),
227                                        ),
228                                        'required'   => array( 'prompt' ),
229                                    ),
230                                ),
231                            ),
232                            'description' => 'Preferred image parameter. One entry per __IMG_N__ placeholder in block_content (in order). Each entry is either a string prompt ("A bright cafe interior, wide angle") or an object {prompt, orientation?, width?, height?}. The client calls blu/generate-image per entry and substitutes each placeholder with the returned URL. The count must match the number of unique __IMG_N__ placeholders.',
233                        ),
234                        'image_urls'       => array(
235                            'type'        => 'array',
236                            'items'       => array( 'type' => 'string' ),
237                            'description' => 'Fallback path for when you already have resolved image URLs. Ignored if image_prompts is provided. Replaces __IMG_1__, __IMG_2__, â€¦ in order.',
238                        ),
239                    ),
240                    'required'   => array( 'block_content' ),
241                ),
242                'execute_callback'    => function ( $input ) {
243                    // Validate: block_content is required
244                    if ( empty( $input['block_content'] ) ) {
245                        return blu_prepare_ability_response( 400, array( 'message' => 'block_content is required' ) );
246                    }
247
248                    // Validate: after_client_id and before_client_id are mutually exclusive.
249                    // Only a non-empty string counts as "set" â€” explicit null on after_client_id
250                    // is the documented way to request "top of page" and must not conflict.
251                    $has_after_param  = ! empty( $input['after_client_id'] ) && is_string( $input['after_client_id'] );
252                    $has_before_param = ! empty( $input['before_client_id'] ) && is_string( $input['before_client_id'] );
253                    if ( $has_after_param && $has_before_param ) {
254                        return blu_prepare_ability_response(
255                            400,
256                            array(
257                                'message' => 'after_client_id and before_client_id are mutually exclusive. Provide only one.',
258                            )
259                        );
260                    }
261
262                    // after_client_id can be null (top of page) or a string
263                    $after_client_id = isset( $input['after_client_id'] ) && '' !== $input['after_client_id']
264                        ? sanitize_text_field( $input['after_client_id'] )
265                        : null;
266
267                    // before_client_id is used to insert BEFORE a given block
268                    $before_client_id = isset( $input['before_client_id'] ) && '' !== $input['before_client_id']
269                        ? sanitize_text_field( $input['before_client_id'] )
270                        : null;
271
272                    // Return action data for client-side execution
273                    $response_data = array(
274                        'action'        => 'add_section',
275                        'block_content' => $input['block_content'],
276                        'message'       => 'Section add ready for execution',
277                    );
278
279                    // Forward positional information based on which parameter is used
280                    if ( null !== $after_client_id ) {
281                        $response_data['after_client_id'] = $after_client_id;
282                    }
283                    if ( null !== $before_client_id ) {
284                        $response_data['before_client_id'] = $before_client_id;
285                    }
286
287                    // Forward image inputs to the client for placeholder substitution.
288                    // image_prompts is the preferred path (client generates via blu/generate-image);
289                    // image_urls is a fallback when the caller already has URLs.
290                    if ( ! empty( $input['image_prompts'] ) && is_array( $input['image_prompts'] ) ) {
291                        $response_data['image_prompts'] = $input['image_prompts'];
292                    }
293                    if ( ! empty( $input['image_urls'] ) && is_array( $input['image_urls'] ) ) {
294                        $response_data['image_urls'] = array_map( 'esc_url_raw', $input['image_urls'] );
295                    }
296
297                    return blu_prepare_ability_response( 200, $response_data );
298                },
299                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
300                'meta'                => array(
301                    'annotations' => array(
302                        'readonly'    => false,
303                        'destructive' => false,
304                        'idempotent'  => false,
305                    ),
306                    'mcp'         => array(
307                        'public' => true,
308                        'type'   => 'tool',
309                    ),
310                ),
311            )
312        );
313    }
314
315    /**
316     * Register ability to delete a block
317     *
318     * Removes a block and all its inner blocks from the page.
319     *
320     * @return void
321     */
322    private function register_delete_block(): void {
323        blu_register_ability(
324            'blu/delete-block',
325            array(
326                'label'               => 'Delete Block',
327                'description'         => 'Remove a block and ALL of its inner blocks from the page. Use this when the user asks to remove, delete, or get rid of a section or block. This action is irreversible without undo. Only delete the specific block the user refers to â€” do not delete parent blocks unless explicitly asked.',
328                'category'            => 'blu-mcp',
329                'input_schema'        => array(
330                    'type'       => 'object',
331                    'properties' => array(
332                        'client_id' => array(
333                            'type'        => 'string',
334                            'description' => 'The clientId of the block to delete, from the block tree context',
335                        ),
336                    ),
337                    'required'   => array( 'client_id' ),
338                ),
339                'execute_callback'    => function ( $input ) {
340                    // Validate required fields
341                    if ( empty( $input['client_id'] ) ) {
342                        return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) );
343                    }
344
345                    // Return action data for client-side execution
346                    return blu_prepare_ability_response(
347                        200,
348                        array(
349                            'action'    => 'delete_block',
350                            'client_id' => sanitize_text_field( $input['client_id'] ),
351                            'message'   => 'Block delete ready for execution',
352                        )
353                    );
354                },
355                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
356                'meta'                => array(
357                    'annotations' => array(
358                        'readonly'    => false,
359                        'destructive' => true,
360                        'idempotent'  => false,
361                    ),
362                    'mcp'         => array(
363                        'public' => true,
364                        'type'   => 'tool',
365                    ),
366                ),
367            )
368        );
369    }
370
371    /**
372     * Register the unified blu/duplicate-block ability.
373     *
374     * Dual-mode: caller passes EITHER `client_id` (explicit target) OR `kind`
375     * (intent-based; client resolves to a concrete block via the block lexicon
376     * and target resolver). Callers should use `kind` whenever the user phrasing
377     * is "another X" / "one more X", and `client_id` only when the user points
378     * at a specific block with "this" / "that".
379     *
380     * @return void
381     */
382    private function register_duplicate(): void {
383        $description = <<<'DESC'
384        Duplicate a block with its exact existing design. Much faster than edit-block â€” no markup is regenerated.
385
386        TWO CALLING MODES. Pick based on how the user phrased the request:
387
388        1. Intent mode (PREFERRED when the user says "another X", "one more X", "add a <kind>" where <kind> matches an existing block on the page):
389           Pass { kind: "<word>" }. The system finds the right block deterministically.
390           - "add another column"      â†’ { kind: "column" }
391           - "add another card"        â†’ { kind: "card" }
392           - "add another button"      â†’ { kind: "button" }
393           - "add another menu item"   â†’ { kind: "menu-item" }
394           - "add one more testimonial"→ { kind: "testimonial" }
395           You may optionally add `scope` (a clientId bounding the search, e.g. a specific section) and `position` ("last" | "first" | integer; default "last").
396
397        2. Explicit mode (only when the user points at a specific block â€” "duplicate THIS", "duplicate that card", or the selection IS exactly what they want another of):
398           Pass { client_id: "<UUID>" } â€” copy the UUID EXACTLY from the block tree's `id:<UUID>` field. Never invent placeholder strings.
399
400        Rule of thumb: if you can name what the user said ("column", "card", "button", â€¦), use Intent mode. Only use Explicit mode when the user is clearly referring to one specific block.
401
402        Known kinds: column, button, buttons, image, heading, paragraph, list, list-item, menu-item, card, testimonial, team-member, pricing-tier, faq-item, section, row. Common aliases (col, cols, btn, cta, img, text, p, cards, testimonials, reviews, member, plan, tier, faq, sections) are normalized automatically. If you're unsure which kind a user's word maps to, pass it as-is â€” the resolver returns a list of known kinds if it doesn't match.
403
404        The clone is inserted immediately after the resolved block as a sibling.
405        DESC;
406
407        blu_register_ability(
408            'blu/duplicate-block',
409            array(
410                'label'               => 'Duplicate Block',
411                'description'         => $description,
412                'category'            => 'blu-mcp',
413                'input_schema'        => array(
414                    'type'                 => 'object',
415                    'properties'           => array(
416                        'client_id' => array(
417                            'type'        => 'string',
418                            'description' => 'Explicit mode: the exact clientId (UUID) of the block to duplicate, copied from the block tree. Use only when the user points at a specific block.',
419                            'pattern'     => '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
420                        ),
421                        'kind'      => array(
422                            'type'        => 'string',
423                            'description' => 'Intent mode: the user-facing word for what they want another of ("column", "card", "button", "menu-item", "testimonial", â€¦). Preferred over client_id for "another X" requests.',
424                        ),
425                        'scope'     => array(
426                            'type'        => 'string',
427                            'description' => 'Intent mode (optional): clientId bounding the search â€” usually a section/container. Defaults to the selected block\'s ancestors.',
428                            'pattern'     => '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
429                        ),
430                        'position'  => array(
431                            'description' => 'Intent mode (optional): which matching sibling to clone. "last" (default), "first", or a 0-based integer index.',
432                            'oneOf'       => array(
433                                array(
434                                    'type' => 'string',
435                                    'enum' => array( 'last', 'first' ),
436                                ),
437                                array( 'type' => 'integer' ),
438                            ),
439                        ),
440                    ),
441                    'additionalProperties' => false,
442                ),
443                'execute_callback'    => function ( $input ) {
444                    $has_client_id = ! empty( $input['client_id'] );
445                    $has_kind      = ! empty( $input['kind'] );
446
447                    if ( ! $has_client_id && ! $has_kind ) {
448                        return blu_prepare_ability_response(
449                            400,
450                            array(
451                                'message' => 'Pass EITHER { kind: "<word>" } (intent mode, preferred for "another X") OR { client_id: "<UUID>" } (explicit mode). Neither was supplied.',
452                            )
453                        );
454                    }
455
456                    $response = array(
457                        'action'  => 'duplicate',
458                        'message' => 'Block duplicate ready for execution',
459                    );
460
461                    if ( $has_client_id ) {
462                        if ( ! preg_match( '/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/', $input['client_id'] ) ) {
463                            return blu_prepare_ability_response(
464                                400,
465                                array(
466                                    'message' => sprintf(
467                                        'client_id must be a real UUID from the block tree. Got: "%s". Either switch to intent mode with { kind: "..." } or re-read the block tree and copy the UUID from id:<UUID>.',
468                                        $input['client_id']
469                                    ),
470                                )
471                            );
472                        }
473                        $response['client_id'] = sanitize_text_field( $input['client_id'] );
474                    }
475
476                    if ( $has_kind ) {
477                        $response['kind'] = sanitize_text_field( $input['kind'] );
478                        if ( ! empty( $input['scope'] ) ) {
479                            $response['scope'] = sanitize_text_field( $input['scope'] );
480                        }
481                        if ( isset( $input['position'] ) ) {
482                            $response['position'] = is_int( $input['position'] ) ? $input['position'] : sanitize_text_field( (string) $input['position'] );
483                        }
484                    }
485
486                    return blu_prepare_ability_response( 200, $response );
487                },
488                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
489                'meta'                => array(
490                    'annotations' => array(
491                        'readonly'    => false,
492                        'destructive' => false,
493                        'idempotent'  => false,
494                    ),
495                    'mcp'         => array(
496                        'public' => true,
497                        'type'   => 'tool',
498                    ),
499                ),
500            )
501        );
502    }
503
504    /**
505     * Register ability to insert a new block as a child of an existing container.
506     *
507     * Use when the user wants to add a NEW child to a container (core/columns,
508     * core/group, core/row, core/stack, core/buttons, etc.) without rewriting the
509     * whole parent. Only the new child's markup is emitted â€” the parent and its
510     * other children are untouched.
511     *
512     * @return void
513     */
514    private function register_insert_inner_block(): void {
515        blu_register_ability(
516            'blu/insert-inner-block',
517            array(
518                'label'               => 'Insert Inner Block',
519                'description'         => 'Insert a new block as a child of an existing container block (core/columns, core/group, core/row, core/stack, core/buttons, etc.). Use this when the user asks to insert a NEW item inside a container whose design does NOT already exist on the page â€” e.g. "add a heading at the top of this group", "insert a button in this buttons row". If a sibling with the same design already exists, prefer blu/duplicate-block instead. Only emit the new child block_content; the parent and its existing children are not touched. Provide parent_client_id (the container) and the new block_content. Optional: index (0-based position; omit to append at the end).',
520                'category'            => 'blu-mcp',
521                'input_schema'        => array(
522                    'type'       => 'object',
523                    'properties' => array(
524                        'parent_client_id' => array(
525                            'type'        => 'string',
526                            'description' => 'The clientId of the CONTAINER block that will receive the new child (from the block tree context).',
527                        ),
528                        'block_content'    => array(
529                            'type'        => 'string',
530                            'description' => 'Valid WordPress block markup for the new child block. Must include proper <!-- wp:blockname {...} --> comments. Only the new child â€” do NOT re-emit the parent.',
531                        ),
532                        'index'            => array(
533                            'type'        => 'integer',
534                            'description' => 'Optional 0-based insert position within the parent. Omit to append at the end.',
535                        ),
536                    ),
537                    'required'   => array( 'parent_client_id', 'block_content' ),
538                ),
539                'execute_callback'    => function ( $input ) {
540                    if ( empty( $input['parent_client_id'] ) ) {
541                        return blu_prepare_ability_response( 400, array( 'message' => 'parent_client_id is required' ) );
542                    }
543                    if ( empty( $input['block_content'] ) ) {
544                        return blu_prepare_ability_response( 400, array( 'message' => 'block_content is required' ) );
545                    }
546                    $response_data = array(
547                        'action'           => 'insert_inner_block',
548                        'parent_client_id' => sanitize_text_field( $input['parent_client_id'] ),
549                        'block_content'    => $input['block_content'],
550                        'message'          => 'Inner block insert ready for execution',
551                    );
552                    if ( isset( $input['index'] ) && is_int( $input['index'] ) ) {
553                        $response_data['index'] = $input['index'];
554                    }
555                    return blu_prepare_ability_response( 200, $response_data );
556                },
557                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
558                'meta'                => array(
559                    'annotations' => array(
560                        'readonly'    => false,
561                        'destructive' => false,
562                        'idempotent'  => false,
563                    ),
564                    'mcp'         => array(
565                        'public' => true,
566                        'type'   => 'tool',
567                    ),
568                ),
569            )
570        );
571    }
572
573    /**
574     * Register ability to move a block
575     *
576     * Moves a block (with all inner blocks) to a new position.
577     * Supports sibling mode (before/after another block) and child mode (into a container block).
578     *
579     * @return void
580     */
581    private function register_move_block(): void {
582        blu_register_ability(
583            'blu/move-block',
584            array(
585                'label'               => 'Move Block',
586                'description'         => 'Move a block (with all its inner blocks) to a new position. Two modes: (1) Sibling mode: provide target_client_id + position to place before/after another block. (2) Child mode: provide as_child_of to move the block inside a container block (e.g. move a column into a columns block). Use this when the user asks to reorder sections, move content up/down, rearrange page layout, or restructure blocks into new containers.',
587                'category'            => 'blu-mcp',
588                'input_schema'        => array(
589                    'type'       => 'object',
590                    'properties' => array(
591                        'client_id'        => array(
592                            'type'        => 'string',
593                            'description' => 'The clientId of the block to move, from the block tree context',
594                        ),
595                        'target_client_id' => array(
596                            'type'        => 'string',
597                            'description' => 'Sibling mode: the clientId of the block to position relative to. Required when using position.',
598                        ),
599                        'position'         => array(
600                            'type'        => 'string',
601                            'enum'        => array( 'before', 'after' ),
602                            'description' => 'Sibling mode: where to place the block relative to the target: "before" or "after"',
603                        ),
604                        'as_child_of'      => array(
605                            'type'        => 'string',
606                            'description' => 'Child mode: the clientId of the container block to move into (e.g. a core/columns block). The block is appended as the last child. Use this to restructure layouts, e.g. moving columns between different columns blocks.',
607                        ),
608                    ),
609                    'required'   => array( 'client_id' ),
610                ),
611                'execute_callback'    => function ( $input ) {
612                    // Validate required fields
613                    if ( empty( $input['client_id'] ) ) {
614                        return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) );
615                    }
616
617                    $has_sibling_mode = ! empty( $input['target_client_id'] ) && ! empty( $input['position'] );
618                    $has_child_mode   = ! empty( $input['as_child_of'] );
619
620                    if ( $has_sibling_mode && $has_child_mode ) {
621                        return blu_prepare_ability_response( 400, array( 'message' => 'Cannot use both sibling mode and child mode' ) );
622                    }
623
624                    if ( ! $has_sibling_mode && ! $has_child_mode ) {
625                        return blu_prepare_ability_response( 400, array( 'message' => 'Provide either (target_client_id + position) for sibling mode or as_child_of for child mode' ) );
626                    }
627
628                    if ( $has_sibling_mode && ! in_array( $input['position'], array( 'before', 'after' ), true ) ) {
629                        return blu_prepare_ability_response( 400, array( 'message' => 'position must be "before" or "after"' ) );
630                    }
631
632                    $response_data = array(
633                        'action'    => 'move_block',
634                        'client_id' => sanitize_text_field( $input['client_id'] ),
635                        'message'   => 'Block move ready for execution',
636                    );
637
638                    if ( $has_child_mode ) {
639                        $response_data['as_child_of'] = sanitize_text_field( $input['as_child_of'] );
640                    } else {
641                        $response_data['target_client_id'] = sanitize_text_field( $input['target_client_id'] );
642                        $response_data['position']         = sanitize_text_field( $input['position'] );
643                    }
644
645                    // Return action data for client-side execution
646                    return blu_prepare_ability_response( 200, $response_data );
647                },
648                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
649                'meta'                => array(
650                    'annotations' => array(
651                        'readonly'    => false,
652                        'destructive' => false,
653                        'idempotent'  => false,
654                    ),
655                    'mcp'         => array(
656                        'public' => true,
657                        'type'   => 'tool',
658                    ),
659                ),
660            )
661        );
662    }
663
664    /**
665     * Register ability to highlight a block
666     *
667     * Selects and flashes a block in the editor to draw the user's attention.
668     * This is a read-only, non-destructive tool â€” no undo needed.
669     *
670     * @return void
671     */
672    private function register_highlight_block(): void {
673        // phpcs:disable Generic.Files.LineLength.TooLong -- Tool description includes inline rules for AI context.
674        $description = <<<'DESC'
675        Highlight and scroll to a specific block in the editor. Use this when the user asks where a block is, asks you to point to something, or when you want to draw attention to a specific block while explaining something. The block will be selected and briefly flash to draw the user's eye.
676
677        ADDITIONAL RULES:
678        - HIGHLIGHTING: When the user asks where a block is, what a block looks like, or asks you to point to something, use blu/highlight-block to select and flash the block. This scrolls it into view and adds a brief visual pulse. Do NOT use this on every tool call â€” only when the user is asking about location or you need to draw attention to a specific block.
679        DESC;
680        // phpcs:enable Generic.Files.LineLength.TooLong
681
682        blu_register_ability(
683            'blu/highlight-block',
684            array(
685                'label'               => 'Highlight Block',
686                'description'         => $description,
687                'category'            => 'blu-mcp',
688                'input_schema'        => array(
689                    'type'       => 'object',
690                    'properties' => array(
691                        'client_id' => array(
692                            'type'        => 'string',
693                            'description' => 'The clientId of the block to highlight, from the block tree context',
694                        ),
695                    ),
696                    'required'   => array( 'client_id' ),
697                ),
698                'execute_callback'    => function ( $input ) {
699                    if ( empty( $input['client_id'] ) ) {
700                        return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) );
701                    }
702
703                    return blu_prepare_ability_response(
704                        200,
705                        array(
706                            'action'    => 'highlight_block',
707                            'client_id' => sanitize_text_field( $input['client_id'] ),
708                            'message'   => 'Block highlight ready for execution',
709                        )
710                    );
711                },
712                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
713                'meta'                => array(
714                    'annotations' => array(
715                        'readonly'    => true,
716                        'destructive' => false,
717                        'idempotent'  => true,
718                    ),
719                    'mcp'         => array(
720                        'public' => true,
721                        'type'   => 'tool',
722                    ),
723                ),
724            )
725        );
726    }
727
728    /**
729     * Register ability to update block attributes without replacing markup.
730     *
731     * Deep-merges new attributes into the existing block â€” no need to read
732     * or rewrite the full markup. Use for colors, font sizes, alignment,
733     * spacing, overlays, gradients, layout, and any block comment JSON change.
734     *
735     * @return void
736     */
737    private function register_update_block_attrs(): void {
738        $description = <<<'DESC'
739        Patch attributes on an existing block â€” deep-merged into whatever is already there. No markup rewrite. Works for any block comment JSON: colors, spacing, layout, border, className, typography, content (text on core/paragraph, core/heading, core/button), url/alt on media blocks, and anything else in the block's attributes. Set a value to null to remove it.
740
741        Use this for ALL tweaks to an existing block: style, content, borders, images, etc. Chain multiple calls in the same turn if you need to customize several blocks (e.g. after duplicate or insert, patch the new block's text, icon, and styling to match the user's request). Much faster than edit-block for incremental changes.
742
743        When the user references text inside a container (a paragraph inside a column, a button inside a group), target the LEAF block's clientId â€” not the container's.
744
745        If the block uses nfd-* utility classes that control the property you're changing, include "className" with that class removed in the same patch â€” the CSS class otherwise silently overrides your attribute.
746        DESC;
747
748        blu_register_ability(
749            'blu/update-block-attrs',
750            array(
751                'label'               => 'Update Block Attributes',
752                'description'         => $description,
753                'category'            => 'blu-mcp',
754                'input_schema'        => array(
755                    'type'       => 'object',
756                    'properties' => array(
757                        'client_id'  => array(
758                            'type'        => 'string',
759                            'description' => 'The clientId of the block to update.',
760                        ),
761                        'attributes' => array(
762                            'type'        => 'object',
763                            'description' => 'An object containing the attributes to merge into the block. All attribute keys (style, className, color, fontSize, etc.) MUST be nested inside this "attributes" object â€” never place them at the top level alongside client_id. Nested objects are deep-merged. Set a key to null to remove it. Correct: {"attributes": {"style": {"color": {"background": "#fff"}}}}. Wrong: {"style": {"color": {"background": "#fff"}}}.',
764                        ),
765                    ),
766                    'required'   => array( 'client_id', 'attributes' ),
767                ),
768                'execute_callback'    => function ( $input ) {
769                    if ( empty( $input['client_id'] ) || empty( $input['attributes'] ) ) {
770                        return blu_prepare_ability_response( 400, array( 'message' => 'client_id and attributes are required' ) );
771                    }
772
773                    return blu_prepare_ability_response(
774                        200,
775                        array(
776                            'action'     => 'update_block_attrs',
777                            'client_id'  => sanitize_text_field( $input['client_id'] ),
778                            'attributes' => $input['attributes'],
779                            'message'    => 'Block attribute update ready for execution',
780                        )
781                    );
782                },
783                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
784                'meta'                => array(
785                    'annotations' => array(
786                        'readonly'    => false,
787                        'destructive' => false,
788                        'idempotent'  => true,
789                    ),
790                    'mcp'         => array(
791                        'public' => true,
792                        'type'   => 'tool',
793                    ),
794                ),
795            )
796        );
797    }
798
799    /**
800     * Register ability to get block markup.
801     *
802     * @return void
803     */
804    private function register_get_block_markup(): void {
805        blu_register_ability(
806            'blu/get-block-markup',
807            array(
808                'label'               => 'Get Block Markup',
809                'description'         => 'Get the full HTML markup of a specific block and its inner blocks. Use this BEFORE calling blu/edit-block when you need to see the current markup of a block that is NOT the selected block. This ensures you can produce accurate replacement markup that preserves existing content the user didn\'t ask to change. You do NOT need to call this for the selected block â€” its markup is already provided in the context.',
810                'category'            => 'blu-mcp',
811                'input_schema'        => array(
812                    'type'       => 'object',
813                    'properties' => array(
814                        'client_id' => array(
815                            'type'        => 'string',
816                            'description' => 'The clientId of the block to get markup for, from the block tree context',
817                        ),
818                    ),
819                    'required'   => array( 'client_id' ),
820                ),
821                'execute_callback'    => function ( $input ) {
822                    // Validate required fields
823                    if ( empty( $input['client_id'] ) ) {
824                        return blu_prepare_ability_response( 400, array( 'message' => 'client_id is required' ) );
825                    }
826
827                    // Return action data for client-side execution
828                    // The client intercepts this and returns serialized block markup directly
829                    return blu_prepare_ability_response(
830                        200,
831                        array(
832                            'action'    => 'get_block_markup',
833                            'client_id' => sanitize_text_field( $input['client_id'] ),
834                            'message'   => 'Block markup request ready for execution',
835                        )
836                    );
837                },
838                'permission_callback' => fn() => current_user_can( 'edit_posts' ),
839                'meta'                => array(
840                    'annotations' => array(
841                        'readonly'    => true,
842                        'destructive' => false,
843                        'idempotent'  => true,
844                    ),
845                    'mcp'         => array(
846                        'public' => true,
847                        'type'   => 'tool',
848                    ),
849                ),
850            )
851        );
852    }
853}