). * * Constitution: singleton + private __construct + zero add_action/add_filter. * Hooks wired by Includes\Main::define_admin_hooks() targeting register_submenu(). * * Page slugs live in Includes\Utilities\AdminPageSlugs (RT-1, 2026-06-17) * so list tables and Settings don't depend on this sibling for constants. */ class Menu { /** @var Menu|null */ protected static $_instance = null; /** @var string */ private $plugin_name; /** @var string */ private $version; /** * Return the singleton instance. * * @return self */ public static function instance(): self { if ( null === self::$_instance ) { self::$_instance = new self(); } return self::$_instance; } /** * Private constructor — use instance(). */ private function __construct() { $this->plugin_name = ACROSSAI_MCP_MANAGER_PLUGIN_NAME_SLUG; $this->version = ACROSSAI_MCP_MANAGER_VERSION; } /** * Register submenus under the shared `acrossai` parent menu (FR-018 / FR-020). * * Positions per FR-020 (avoiding collision with acrossai-abilities-manager's * position 1 for Abilities): * - Position 2 — MCP Manager main (Servers listing) * - Position 4 — Access Control (conditional on vendor package presence) * * Render callbacks delegate to Settings::instance() so that Settings * owns all page rendering and stays the single source of truth for * admin HTML (Constitution Admin Partials Rule). * * The shared parent menu itself is bootstrapped by \AcrossAI_Main_Menu\SettingsPage * in acrossai-mcp-manager.php on plugins_loaded priority 0 (FR-029 / D15 / DEV4). */ public function register_submenu(): void { $settings = Settings::instance(); // 1) MCP main page — position 2 under `acrossai` parent (FR-020). add_submenu_page( SettingsPage::PARENT_SLUG, __( 'MCP', 'acrossai-mcp-manager' ), __( 'MCP', 'acrossai-mcp-manager' ), 'manage_options', AdminPageSlugs::PARENT, array( $settings, 'render_list_page' ), 2 ); // 2) Connect via AcrossAI — position 3, right after MCP (F072 FR-003). // URL-literal menu_slug + empty render callback = WP renders this // submenu item as a direct link to the wizard URL; no dedicated // page is created because ?quick-connect=1 is handled by the MCP // page's own dispatcher (Settings::render_list_page). add_submenu_page( SettingsPage::PARENT_SLUG, // Submenu label is deliberately the short form — the sidebar // entry lives under the AcrossAI parent, so the "via AcrossAI" // tail is redundant. Every other surface (plugins.php action // link, servers-list button, per-row pill, Settings sub-nav, // admin-bar chip) keeps the full "Connect via AcrossAI" // so the wizard is discoverable outside the sidebar too. __( 'Connect', 'acrossai-mcp-manager' ), __( 'Connect', 'acrossai-mcp-manager' ), 'manage_options', 'admin.php?page=' . AdminPageSlugs::PARENT . '&quick-connect=1&step=1', '', 3 ); } /** * Prepend a "Settings" action link to the plugin's row on the Plugins screen. * Wired via `plugin_action_links_` so this callback only fires * for this plugin (FR-003). * * S5/B6: admin_url() output is wrapped with esc_url() at the boundary. * * @param array $links Existing action links. * @return array Modified links. */ public function plugin_action_links( $links ): array { // Settings link targets the shared AcrossAI Settings page with the MCP // tab preselected. Servers and Quick Connect are reached from the // AcrossAI admin menu; the tab body no longer carries a sub-nav. $settings_url = esc_url( admin_url( 'admin.php?page=' . SettingsPage::SETTINGS_SLUG . '&tab=' . SettingsMenu::TAB_SLUG ) ); $settings_link = sprintf( '%s', $settings_url, esc_html__( 'Settings', 'acrossai-mcp-manager' ) ); $quick_connect_url = esc_url( admin_url( 'admin.php?page=' . AdminPageSlugs::PARENT . '&quick-connect=1&step=1&server=1' ) ); $quick_connect_link = sprintf( '%s', $quick_connect_url, esc_html__( 'Connect via AcrossAI', 'acrossai-mcp-manager' ) ); // Prepend Connect via AcrossAI first, then Settings, so the final row order // reads Settings | Connect via AcrossAI | Deactivate | Download (F072 FR-002). array_unshift( $links, $quick_connect_link ); array_unshift( $links, $settings_link ); return $links; } }