$playbook Stored playbook. * @return array{uses:int,unavailable:int} */ function aafm_playbook_admin_counts( array $playbook ): array { $uses = $playbook['abilities'] ?? array(); return array( 'uses' => count( $uses ), 'unavailable' => count( aafm_playbook_unavailable_abilities( $uses ) ), ); } /** * The "Uses N abilities, M are off here" text. * * @param int $uses Abilities named. * @param int $unavailable Of those, how many are off. * @return string */ function aafm_playbook_uses_text( int $uses, int $unavailable ): string { /* translators: %d: how many abilities the playbook names. */ $text = sprintf( _n( 'Uses %d ability', 'Uses %d abilities', $uses, 'agent-abilities-for-mcp' ), $uses ); if ( $unavailable > 0 ) { /* translators: 1: "Uses N abilities". 2: how many of them are off on this site. */ $text = sprintf( _n( '%1$s, %2$d is off here', '%1$s, %2$d are off here', $unavailable, 'agent-abilities-for-mcp' ), $text, $unavailable ); } return $text; } /** * One row of the "Your playbooks" table. * * @param array $playbook Stored playbook. * @return string */ function aafm_playbook_row_html( array $playbook ): string { $counts = aafm_playbook_admin_counts( $playbook ); $updated = '' !== $playbook['updated_gmt'] ? substr( (string) $playbook['updated_gmt'], 0, 16 ) . ' UTC' : ''; $active = (bool) $playbook['active']; $uses = aafm_playbook_uses_text( $counts['uses'], $counts['unavailable'] ); $title = (string) $playbook['title']; return sprintf( '' . '%6$s' . '%8$s' . '%9$s' . '%10$s' . ' ' . '', esc_attr( (string) $playbook['slug'] ), esc_attr( $title ), esc_attr( (string) $playbook['description'] ), esc_attr( (string) $playbook['body'] ), $active ? '1' : '0', esc_html( $title ), $active ? 'aafm-pill-success' : 'aafm-pill-neutral', $active ? esc_html__( 'Active', 'agent-abilities-for-mcp' ) : esc_html__( 'Off', 'agent-abilities-for-mcp' ), esc_html( $uses ), esc_html( $updated ), esc_html__( 'Edit', 'agent-abilities-for-mcp' ), esc_html__( 'Delete', 'agent-abilities-for-mcp' ), esc_attr( (string) wp_json_encode( array_values( (array) $playbook['abilities'] ) ) ), esc_attr( (string) ( $playbook['starter'] ?? '' ) ), /* translators: %s: playbook title. */ esc_attr( sprintf( __( 'Edit %s', 'agent-abilities-for-mcp' ), $title ) ), /* translators: %s: playbook title. */ esc_attr( sprintf( __( 'Delete %s', 'agent-abilities-for-mcp' ), $title ) ), esc_attr( aafm_playbook_version( $playbook ) ) ); } /** * The "Find more playbooks on our website" link. * * @return string */ function aafm_playbook_library_link_html(): string { return sprintf( '%2$s %3$s', esc_url( AAFM_PLAYBOOK_LIBRARY_URL ), esc_html__( 'Find more playbooks on our website', 'agent-abilities-for-mcp' ), esc_html__( '(opens in a new tab)', 'agent-abilities-for-mcp' ) ); } /** * The ability picker for the editor: every ability of this plugin by subject, each with its state. * * An ability is "Plugin not active" when the live (host-gated) registry lacks it, "Off" when it is * not enabled, else it carries no pill. Off abilities can still be ticked. * * @return string */ function aafm_playbook_picker_html(): string { $enabled = aafm_get_enabled_abilities(); $live = aafm_get_abilities_registry(); $groups = ''; foreach ( aafm_allowlist_ability_catalog() as $group ) { $items = ''; foreach ( $group['abilities'] as $ability ) { $name = (string) $ability['name']; $pill = ''; if ( ! isset( $live[ $name ] ) ) { $pill = ' ' . esc_html__( 'Plugin not active', 'agent-abilities-for-mcp' ) . ''; } elseif ( ! in_array( $name, $enabled, true ) ) { $pill = ' ' . esc_html__( 'Off', 'agent-abilities-for-mcp' ) . ''; } $items .= sprintf( '', esc_attr( $name ), esc_html( (string) $ability['label'] ), $pill ); } $groups .= '
' . esc_html( (string) $group['label'] ) . '' . $items . '
'; } if ( '' === $groups ) { return ''; } return '
' . '' . '
' . '

' . esc_html__( 'No abilities chosen yet.', 'agent-abilities-for-mcp' ) . '

' . '
' . '
' . $groups . '
' . '
'; } /** * Render the Playbooks tab: your playbooks, the starters, then the editor. * * @return void */ function aafm_render_playbooks_tab(): void { $playbooks = aafm_playbooks(); $enabled = aafm_get_enabled_abilities(); $both_on = in_array( 'aafm/list-playbooks', $enabled, true ) && in_array( 'aafm/get-playbook', $enabled, true ); $any_on = array() !== array_filter( array_column( $playbooks, 'active' ) ); echo '
'; // Card 1: the library. ob_start(); if ( $any_on && ! $both_on ) { $abilities_url = add_query_arg( array( 'page' => 'agent-abilities-for-mcp', 'tab' => 'abilities', ), admin_url( 'admin.php' ) ); aafm_render_notice( 'info', sprintf( /* translators: %s: link to the Abilities tab. */ __( 'Agents cannot see your Active playbooks yet. Turn on List playbooks and Get playbook in the Site section of the %s.', 'agent-abilities-for-mcp' ), '' . esc_html__( 'Abilities tab', 'agent-abilities-for-mcp' ) . '' ), array( 'inline' => true, 'html' => true, ) ); } echo '
' . esc_html__( 'No playbooks yet. Add a starter below or write your own.', 'agent-abilities-for-mcp' ) . '
' . aafm_playbook_library_link_html() . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the link helper escapes every part. echo '
'; echo ''; foreach ( $playbooks as $playbook ) { echo aafm_playbook_row_html( $playbook ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- every value escaped in aafm_playbook_row_html(). } echo '
' . esc_html__( 'Title', 'agent-abilities-for-mcp' ) . '' . esc_html__( 'Status', 'agent-abilities-for-mcp' ) . '' . esc_html__( 'Uses', 'agent-abilities-for-mcp' ) . '' . esc_html__( 'Updated', 'agent-abilities-for-mcp' ) . '' . esc_html__( 'Actions', 'agent-abilities-for-mcp' ) . '
'; echo '

'; aafm_render_section( array( 'id' => 'aafm-playbooks-list', 'icon' => 'playbooks', 'title' => __( 'Your playbooks', 'agent-abilities-for-mcp' ), 'description' => __( 'A playbook is a set of steps you write for agents, such as how to check a draft or build a weekly report. Agents can list and load your Active playbooks once List playbooks and Get playbook are on and the connection\'s role and access rules allow them.', 'agent-abilities-for-mcp' ), 'body' => (string) ob_get_clean(), ) ); // Card 2: starters, right under the library so a new owner finds them first. ob_start(); $taken = array_column( $playbooks, 'starter' ); echo ''; echo '

'; echo '

' . aafm_playbook_library_link_html() . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the link helper escapes every part. aafm_render_section( array( 'id' => 'aafm-playbook-starters', 'icon' => 'star', 'title' => __( 'Starter playbooks', 'agent-abilities-for-mcp' ), 'description' => __( 'Ready-made playbooks to start from. An added starter arrives switched Off, so you can read and edit it before agents see it.', 'agent-abilities-for-mcp' ), 'body' => (string) ob_get_clean(), ) ); // Card 3: the editor. ob_start(); echo '
'; echo ''; echo ''; aafm_render_set_row( array( 'label' => __( 'Title', 'agent-abilities-for-mcp' ), 'control' => sprintf( '', esc_attr__( 'Playbook title', 'agent-abilities-for-mcp' ) ), ) ); aafm_render_set_row( array( 'label' => __( 'Description', 'agent-abilities-for-mcp' ), 'opt' => __( 'Optional', 'agent-abilities-for-mcp' ), 'control' => sprintf( '', esc_attr__( 'Playbook description: one line agents see in the list', 'agent-abilities-for-mcp' ) ), ) ); aafm_render_set_row( array( 'label' => __( 'Steps', 'agent-abilities-for-mcp' ), 'control' => sprintf( '', esc_attr__( 'Playbook steps', 'agent-abilities-for-mcp' ) ), 'help' => sprintf( /* translators: 1: longest title, 2: longest description, 3: longest body, in characters. */ __( 'Plain text. Title up to %1$d characters, description up to %2$d, steps up to %3$s. Keep passwords and private details out of it: agents read it word for word. Site administrators can change it; agents cannot.', 'agent-abilities-for-mcp' ), AAFM_PLAYBOOK_TITLE_MAX, AAFM_PLAYBOOK_DESC_MAX, number_format_i18n( AAFM_PLAYBOOK_BODY_MAX ) ), ) ); aafm_render_set_row( array( 'label' => __( 'Abilities it uses', 'agent-abilities-for-mcp' ), 'control' => aafm_playbook_picker_html(), 'help' => __( 'Tick the abilities an agent needs for these steps. Agents get this list with the playbook, so you can write the steps in plain words.', 'agent-abilities-for-mcp' ), ) ); aafm_render_set_row( array( 'label' => __( 'Active', 'agent-abilities-for-mcp' ), 'control' => '', 'help' => __( 'Only Active playbooks reach agents.', 'agent-abilities-for-mcp' ), ) ); echo '
'; echo '
'; aafm_render_section( array( 'id' => 'aafm-playbook-editor', 'icon' => 'terminal', 'title' => __( 'Write a playbook', 'agent-abilities-for-mcp' ), 'body' => (string) ob_get_clean(), ) ); echo '
'; } /** * Check the nonce and capability for a Playbooks AJAX action, ending the request when either fails. * * @return void */ function aafm_playbook_ajax_guard(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } } /** * Send a saved playbook back to the tab, or the refusal. * * @param array|WP_Error $result Stored entry or error. * @return void */ function aafm_playbook_ajax_send( $result ): void { if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } $counts = aafm_playbook_admin_counts( $result ); wp_send_json_success( array( 'playbook' => $result, 'version' => aafm_playbook_version( $result ), 'uses_count' => $counts['uses'], 'unavailable_count' => $counts['unavailable'], ) ); } /** * AJAX: create or update a playbook. * * @return void */ function aafm_ajax_save_playbook(): void { aafm_playbook_ajax_guard(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified in aafm_playbook_ajax_guard(). $post = wp_unslash( $_POST ); // phpcs:enable WordPress.Security.NonceVerification.Missing $slug = isset( $post['slug'] ) && is_string( $post['slug'] ) ? sanitize_title( $post['slug'] ) : ''; aafm_playbook_ajax_send( aafm_save_playbook( array( 'title' => $post['title'] ?? '', 'description' => $post['description'] ?? '', 'body' => $post['body'] ?? '', 'active' => isset( $post['active'] ) && '1' === $post['active'], 'abilities' => $post['abilities'] ?? '', 'version' => $post['version'] ?? '', ), $slug ) ); } /** * AJAX: delete a playbook. * * @return void */ function aafm_ajax_delete_playbook(): void { aafm_playbook_ajax_guard(); $slug = isset( $_POST['slug'] ) ? sanitize_title( wp_unslash( (string) $_POST['slug'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in aafm_playbook_ajax_guard(). $result = aafm_delete_playbook( $slug ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } wp_send_json_success( array( 'slug' => $slug ) ); } /** * AJAX: add a starter playbook. * * @return void */ function aafm_ajax_add_starter_playbook(): void { aafm_playbook_ajax_guard(); $starter = isset( $_POST['starter'] ) ? sanitize_key( wp_unslash( (string) $_POST['starter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified in aafm_playbook_ajax_guard(). aafm_playbook_ajax_send( aafm_add_starter_playbook( $starter ) ); }