settings = $settings; } /** * Hook the menu, assets and the plugin-list shortcut. */ public function register() { add_action( 'admin_menu', array( $this, 'menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'menu_icon_style' ) ); add_filter( 'plugin_action_links_' . plugin_basename( AGENTIMUS_FILE ), array( $this, 'action_links' ) ); add_action( 'admin_init', array( $this, 'maybe_activation_redirect' ) ); // Quick-access node in the toolbar (front-end + admin), with its icon. add_action( 'admin_bar_menu', array( $this, 'admin_bar_node' ), 80 ); add_action( 'wp_enqueue_scripts', array( $this, 'admin_bar_style' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'admin_bar_style' ) ); // Native admin-footer text + version, on our own screens ONLY (the // scoped WordPress convention WooCommerce and others follow — we never // touch the global admin footer or any unrelated screen). add_filter( 'admin_footer_text', array( $this, 'admin_footer_text' ) ); add_filter( 'update_footer', array( $this, 'admin_footer_version' ), 15 ); // Keep our own screen clean: WordPress prints every other plugin's // admin_notices on every admin page. On the Agentimus screen ONLY, clear // those queues before they render (same scoped convention as above). add_action( 'in_admin_header', array( $this, 'suppress_foreign_notices' ), 1 ); } /** * On the Agentimus admin screen, remove all queued admin notices so other * plugins' banners don't intrude on our app-like UI. Runs on `in_admin_header`, * before WordPress prints the notices, and is a no-op on every other screen. * Agentimus registers no admin_notices of its own, so nothing of ours is lost. */ public function suppress_foreign_notices() { if ( ! function_exists( 'get_current_screen' ) ) { return; } $screen = get_current_screen(); if ( ! $screen || 'toplevel_page_' . self::SLUG !== $screen->id ) { return; } remove_all_actions( 'admin_notices' ); remove_all_actions( 'all_admin_notices' ); remove_all_actions( 'user_admin_notices' ); remove_all_actions( 'network_admin_notices' ); } /** * Register the top-level menu. */ public function menu() { add_menu_page( __( 'Agentimus', 'agentimus' ), __( 'Agentimus', 'agentimus' ), 'manage_options', self::SLUG, array( $this, 'render' ), $this->menu_icon_uri(), 81 ); } /** * The brand "A" monogram as a single-colour SVG data URI for the admin-menu * icon — the same mark as the in-app logo and the wp.org icon, in the line form * a menu icon needs. The stroke colour is a sensible idle fallback; menu_icon_style() * recolours it per state (idle grey -> white on hover/current) via a CSS mask. * * @return string */ private function menu_icon_uri() { $svg = ''; return 'data:image/svg+xml;base64,' . base64_encode( $svg ); } /** * Recolour the SVG menu icon to match a native dashicon: masked by the "A", filled * with the menu's per-state icon colour (idle grey, white when hovered/current), * so it adapts across admin colour schemes instead of being a fixed-colour image. * * Attached to a src-less registered handle via wp_add_inline_style() — the menu is * present on every admin screen, so this runs on all of them, independent of the * plugin app enqueued in assets(). The mask URL is a static, plugin-generated SVG * data URI (no user input), so it is safe to inline verbatim. */ public function menu_icon_style() { $uri = $this->menu_icon_uri(); $sel = '#adminmenu #toplevel_page_' . self::SLUG; $handle = self::HANDLE . '-menu'; $css = $sel . ' .wp-menu-image{background-image:none!important;position:relative}' . $sel . ' .wp-menu-image::before{content:"";position:absolute;inset:0;background-color:rgba(240,246,252,.6);-webkit-mask:url("' . $uri . '") center/21px no-repeat;mask:url("' . $uri . '") center/21px no-repeat}' . $sel . ':hover .wp-menu-image::before,' . $sel . '.current .wp-menu-image::before,' . $sel . '.wp-has-current-submenu .wp-menu-image::before,' . $sel . '.opensub .wp-menu-image::before{background-color:#fff}'; wp_register_style( $handle, false, array(), AGENTIMUS_VERSION ); wp_enqueue_style( $handle ); wp_add_inline_style( $handle, $css ); } /** * Quick-access node in the WordPress toolbar so Agentimus is one click away * from anywhere — the front-end bar and every other admin screen. * * Hidden on the plugin's own screen (you're already there) and shown only to * users who can actually open it. The child nodes deep-link into the SPA's * tabs, which route off the URL hash (see action_links()). * * Placed in the right-hand `top-secondary` group; at this hook priority (80) * it falls between the account node (priority 0) and the search node (9999), * so the float:right layout renders it immediately to the left of "Howdy". * * @param \WP_Admin_Bar $bar The toolbar being built. */ public function admin_bar_node( $bar ) { if ( ! current_user_can( 'manage_options' ) || $this->is_plugin_screen() ) { return; } $base = admin_url( 'admin.php?page=' . self::SLUG ); $bar->add_node( array( 'id' => self::SLUG, 'parent' => 'top-secondary', 'title' => '' . esc_html__( 'Agentimus', 'agentimus' ), 'href' => esc_url( $base ), 'meta' => array( 'title' => esc_attr__( 'Open Agentimus', 'agentimus' ) ), ) ); $tabs = array( 'dashboard' => __( 'Dashboard', 'agentimus' ), 'settings' => __( 'Settings', 'agentimus' ), 'readiness' => __( 'Readiness', 'agentimus' ), 'discovery' => __( 'Discovery', 'agentimus' ), ); foreach ( $tabs as $tab => $label ) { $bar->add_node( array( 'parent' => self::SLUG, 'id' => self::SLUG . '-' . $tab, 'title' => $label, 'href' => esc_url( $base . '#' . $tab ), ) ); } } /** * Whether the current request is the Agentimus admin screen — used to hide * the toolbar shortcut when it would just point at the page you're on. * * @return bool */ private function is_plugin_screen() { if ( ! is_admin() ) { return false; } if ( function_exists( 'get_current_screen' ) ) { $screen = get_current_screen(); if ( $screen && 'toplevel_page_' . self::SLUG === $screen->id ) { return true; } } // Fallback for hooks that fire before the screen object is set. return isset( $_GET['page'] ) && self::SLUG === sanitize_key( wp_unslash( $_GET['page'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check, no state change. } /** * Left admin-footer text on our own screens: a polite, optional rating * request. Off our screens we return WordPress's default text untouched, so * the global admin experience is never altered (a wp.org guideline). The * name and the star link are pre-escaped; the translatable string carries * only placeholders. * * @param string $text Default footer text. * @return string */ public function admin_footer_text( $text ) { if ( ! $this->is_plugin_screen() ) { return $text; } $name = '' . esc_html__( 'Agentimus', 'agentimus' ) . ''; $stars = sprintf( '★★★★★', esc_url( 'https://wordpress.org/support/plugin/agentimus/reviews/?rate=5#new-post' ), esc_attr__( 'Rate Agentimus five stars on WordPress.org (opens in a new tab)', 'agentimus' ) ); return sprintf( /* translators: 1: plugin name (bold), 2: five-star rating link. */ __( 'If you like %1$s please give it a %2$s rating. A huge thanks in advance!', 'agentimus' ), $name, $stars ); } /** * Right admin-footer text on our own screens: the plugin version alongside the * running WordPress version (priority 15 so it wins over core's WP-version line). * Off our screens core's default version/update text is left intact. * * @param string $text Default upgrade/version text. * @return string */ public function admin_footer_version( $text ) { if ( ! $this->is_plugin_screen() ) { return $text; } // The "|" is a decorative, letterpress-styled separator (see .agentimus-footer-sep // in app.css); aria-hidden so screen readers skip the glyph. The version values are // escaped; the span is our own static markup, and update_footer output is not // auto-escaped by core (it renders HTML, as the star-rating link above does). $sep = ''; return sprintf( /* translators: 1: Agentimus plugin version, 2: separator glyph, 3: WordPress core version. */ esc_html__( 'Agentimus - %1$s %2$s WordPress - %3$s', 'agentimus' ), esc_html( AGENTIMUS_VERSION ), $sep, esc_html( get_bloginfo( 'version' ) ) ); } /** * Style the toolbar node's brand monogram. Reuses the menu icon's SVG as a * CSS mask filled with `currentColor`, so the "A" tracks the toolbar's own * text colour in every admin scheme and on the front-end bar alike. Enqueued * on both front and admin because the toolbar shows in both. */ public function admin_bar_style() { if ( ! is_admin_bar_showing() || ! current_user_can( 'manage_options' ) ) { return; } $uri = $this->menu_icon_uri(); $sel = '#wpadminbar #wp-admin-bar-' . self::SLUG; $handle = self::HANDLE . '-adminbar'; $css = $sel . ' .ab-icon::before{content:"";display:inline-block;width:16px;height:16px;' . 'margin:0 2px 0 0;vertical-align:middle;background-color:currentColor;' . '-webkit-mask:url("' . $uri . '") center/contain no-repeat;' . 'mask:url("' . $uri . '") center/contain no-repeat}'; wp_register_style( $handle, false, array(), AGENTIMUS_VERSION ); wp_enqueue_style( $handle ); wp_add_inline_style( $handle, $css ); } /** * "Settings" link on the Plugins screen. * * @param array $links Existing links. * @return array */ public function action_links( $links ) { // Deep-link to the Settings tab: the SPA reads the URL hash on load, so // "#settings" lands there instead of the default Dashboard. $url = admin_url( 'admin.php?page=' . self::SLUG ) . '#settings'; array_unshift( $links, '' . esc_html__( 'Settings', 'agentimus' ) . '' ); return $links; } /** * One-time redirect to the plugin screen right after a fresh activation, so a * non-technical admin lands on the setup wizard instead of the Plugins list. * Guarded against AJAX, network admin and bulk activation; the transient is * one-shot. */ public function maybe_activation_redirect() { if ( ! get_transient( 'agentimus_activation_redirect' ) ) { return; } delete_transient( 'agentimus_activation_redirect' ); // Never hijack a bulk activation or a network-admin context. if ( wp_doing_ajax() || is_network_admin() || isset( $_GET['activate-multi'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading WordPress's own bulk-activation marker, no state change. return; } if ( ! current_user_can( 'manage_options' ) ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG ) ); exit; } /** * Whether the setup wizard is done — or shouldn't appear because the install * is clearly already configured. The explicit flag is set when the wizard is * finished or skipped (and on activation for pre-existing installs). The * heuristic additionally suppresses the wizard for installs that updated via * wordpress.org without re-running the activation hook: any sign of prior * configuration — a profile sentence, or a content selection that differs * from the fresh-install default — counts as onboarded. * * @return bool */ private function is_onboarded() { if ( false !== get_option( 'agentimus_onboarded', false ) ) { return true; } if ( '' !== trim( (string) $this->settings->identity( 'about', '' ) ) ) { return true; } $selected = (array) $this->settings->get( 'post_types', array() ); $default = Settings::default_post_types(); sort( $selected ); sort( $default ); return $selected !== $default; } /** * Enqueue the built Vue app on our screen only. * * @param string $hook Current admin page hook. */ public function assets( $hook ) { if ( 'toplevel_page_' . self::SLUG !== $hook ) { return; } $js = AGENTIMUS_DIR . 'assets/admin/app.js'; $css = AGENTIMUS_DIR . 'assets/admin/app.css'; if ( is_readable( $js ) ) { wp_enqueue_script( self::HANDLE, AGENTIMUS_URL . 'assets/admin/app.js', array(), $this->asset_version( $js ), true ); wp_localize_script( self::HANDLE, 'AgentimusData', $this->bootstrap_data() ); } if ( is_readable( $css ) ) { wp_enqueue_style( self::HANDLE, AGENTIMUS_URL . 'assets/admin/app.css', array(), $this->asset_version( $css ) ); // Gold, underline-free stars for the admin-footer rating link (the // footer sits outside the Vue app, so it needs a global selector). wp_add_inline_style( self::HANDLE, '.agentimus-rating-link{color:#e0a32e;text-decoration:none}.agentimus-rating-link:hover,.agentimus-rating-link:focus{color:#c8881d}' ); } } /** * Data handed to the Vue app at boot. * * @return array */ /** * The AEO/GEO score, computed fail-open. It samples posts (HTML parsing) and reads * the visibility store — more moving parts than the rest of the boot payload — so a * throw here must degrade to "no score card" (null), never take down the whole admin * screen. The rail's card is guarded on this value, so null just hides it. * * @param array> $readiness Precomputed readiness rows. * @return array|null */ private function aeo_score( $readiness ) { try { return ( new Score( $this->settings ) )->report( $readiness ); } catch ( \Throwable $e ) { return null; } } private function bootstrap_data() { return array( 'restUrl' => esc_url_raw( rest_url( Rest::NAMESPACE ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'settings' => $this->settings->all(), 'defaults' => $this->settings->defaults(), // Powers the reset-preview. 'readiness' => $readiness = ( new Readiness( $this->settings ) )->report(), 'score' => $this->aeo_score( $readiness ), // AEO/GEO score + action plan, from the same readiness run. 'discovery' => Discovery\Hub::data( $this->settings, Discovery\Registry::instance() ), 'restNamespacesDetected' => Discovery\Adapters\RestApi::detected(), 'entityTypes' => $this->settings->entity_types(), // The retention/cap dropdowns render from these, and Settings::sanitize() snaps to // the same constants — so the form can never offer a value the sanitiser rejects. 'retentionChoices' => Settings::RETENTION_CHOICES, 'maxRowsChoices' => Settings::MAX_ROWS_CHOICES, 'postTypes' => $this->available_post_types(), 'categories' => $this->category_options(), 'knownTrainers' => Settings::known_trainers(), 'knownScanners' => Settings::known_scanners(), 'knownAllowed' => Settings::known_allowed(), 'defaultAllowed' => Guard::default_allowed(), 'endpoints' => array( 'llms' => home_url( '/llms.txt' ), 'llmsFull' => home_url( '/llms-full.txt' ), 'robots' => home_url( '/robots.txt' ), ), 'version' => AGENTIMUS_VERSION, // Surfaced on the About tab so the protocol facts mirror the code, // not hand-copied strings that can drift. 'protocol' => array( 'name' => 'WP_Discovery', 'version' => Discovery\Envelope::SPEC_VERSION, 'hook' => \AGENTIMUS_CANONICAL_HOOK, 'specUrl' => 'https://github.com/heera/wp-discovery-protocol', 'schemaUrl' => Discovery\Envelope::schema_url(), ), // Every registered WebMCP tool (baseline + provider-added), so the // Settings panel can list them with a per-tool expose/hide toggle. 'webmcpTools' => array_map( static function ( $t ) { return array( 'name' => $t['name'], 'description' => isset( $t['description'] ) ? $t['description'] : '', ); }, ( new WebMcp( $this->settings ) )->registered_tools() ), 'onboarded' => $this->is_onboarded(), 'llmsFullEstimate' => Content::estimate_full_size( $this->settings ), // A real published, in-scope permalink for the live self-check to probe // (markdown + its advertised Link). '' when the site has no such post yet. 'samplePost' => $this->sample_post_url(), // Sensitive paths the browser-side "exposed files" scan probes for (built-in // list + the owner's extra paths). The list is public attack paths; the scan // itself runs in the browser, same-origin — the server makes no request. 'exposedPaths' => Exposure::sensitive_paths( $this->settings ), // WordPress debug posture (read-only) for the Exposure tab's status card: // warns when debug logging/display is left on in production. No request. 'debug' => Exposure::debug_status(), // Whether this looks like a local/dev site (host-based, never a false positive // on a public site) — softens the exposed-files scan from "publicly downloadable" // to a deploy-time heads-up. 'isLocal' => Exposure::is_local_environment(), ); } /** * The most recent published permalink among the agent-visible post types — the * page the live self-check fetches to confirm markdown delivery and the * advertised `.md` Link work end to end. Empty when there's nothing to probe. * * @return string */ private function sample_post_url() { $types = Content::post_types(); if ( empty( $types ) ) { return ''; } $posts = get_posts( array( 'post_type' => $types, 'post_status' => 'publish', 'numberposts' => 1, 'orderby' => 'date', 'order' => 'DESC', 'suppress_filters' => false, ) ); return empty( $posts ) ? '' : (string) get_permalink( $posts[0] ); } /** * Available public post types as { slug, label } for the settings UI. * * @return array */ private function available_post_types() { $out = array(); foreach ( Content::available() as $slug ) { $out[] = array( 'slug' => $slug, 'label' => Content::label( $slug ), 'source' => Content::source( $slug ), ); } return $out; } /** * The site's categories (id + name), for the "evergreen categories" picker in * Settings. Bounded, most-used first, so a huge taxonomy can't bloat the payload. * * @return array */ private function category_options() { $terms = get_categories( array( 'hide_empty' => false, 'number' => 200, 'orderby' => 'count', 'order' => 'DESC' ) ); if ( is_wp_error( $terms ) || ! is_array( $terms ) ) { return array(); } $out = array(); foreach ( $terms as $t ) { $out[] = array( 'id' => (int) $t->term_id, 'name' => $t->name ); } return $out; } /** * Mount point (and a graceful notice if the app hasn't been built yet). */ public function render() { echo '
'; if ( ! is_readable( AGENTIMUS_DIR . 'assets/admin/app.js' ) ) { echo '

' . esc_html__( 'The admin interface has not been built yet. Run "npm install && npm run build" in the plugin directory.', 'agentimus' ) . '

'; } echo '
'; } /** * Cache-busting version: the asset's own file mtime, so a rebuilt bundle is * always served fresh (no plugin-version bump or WP_DEBUG needed). Falls back to * the plugin version only if the file can't be read. * * @param string $path Absolute file path. * @return string */ private function asset_version( $path ) { $mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return $mtime ? (string) $mtime : AGENTIMUS_VERSION; } }