From e2a490560610ff5edd343913ccce06d0ba383d10 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 30 Apr 2026 17:41:10 -0400 Subject: [PATCH 1/6] feat(dashboard): add Plugins page with enable/disable, auth status, install/remove - New PluginsPage.tsx: full plugin management UI (list, enable/disable, install from git, remove, git pull updates, provider picker) - Backend: dashboard_set_agent_plugin_enabled now also toggles the plugin's toolset in platform_toolsets so enabling actually makes tools visible in agent sessions - Backend: /api/dashboard/plugins/hub returns auth_required + auth_command per plugin (checks tool registry check_fn) - Frontend: auth_required shown as Badge + CommandBlock with copy-able auth command - Fix: Select overflow in providers card (min-w-0 grid cells, removed truncate/overflow-hidden that clipped dropdown) - Refactor: _install_plugin_core extracted for non-interactive reuse, PluginOperationError for structured error handling - i18n: en/zh/types updated with all new plugin page strings --- hermes_cli/plugins_cmd.py | 491 ++++++++++++++++++++++------- hermes_cli/web_server.py | 217 +++++++++++++ ui-tui/package-lock.json | 41 +-- web/src/App.tsx | 169 +++++++--- web/src/i18n/en.ts | 41 +++ web/src/i18n/types.ts | 40 +++ web/src/i18n/zh.ts | 40 +++ web/src/lib/api.ts | 98 ++++++ web/src/pages/PluginsPage.tsx | 569 ++++++++++++++++++++++++++++++++++ web/src/plugins/slots.ts | 4 + 10 files changed, 1521 insertions(+), 189 deletions(-) create mode 100644 web/src/pages/PluginsPage.tsx diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 352dadd194..a13e1b212c 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -15,13 +15,18 @@ import shutil import subprocess import sys from pathlib import Path -from typing import Optional +from typing import Any, Optional from hermes_constants import get_hermes_home from hermes_cli.config import cfg_get logger = logging.getLogger(__name__) + +class PluginOperationError(Exception): + """Recoverable plugin install/update failure (CLI exits; HTTP maps to 4xx).""" + + # Minimum manifest version this installer understands. # Plugins may declare ``manifest_version: 1`` in plugin.yaml; # future breaking changes to the manifest schema bump this. @@ -150,6 +155,24 @@ def _copy_example_files(plugin_dir: Path, console) -> None: ) +def _missing_requires_env_names(manifest: dict) -> list[str]: + """Return declared ``requires_env`` names that are unset in ``~/.hermes/.env``.""" + requires_env = manifest.get("requires_env") or [] + if not requires_env: + return [] + + from hermes_cli.config import get_env_value + + env_specs: list[dict] = [] + for entry in requires_env: + if isinstance(entry, str): + env_specs.append({"name": entry}) + elif isinstance(entry, dict) and entry.get("name"): + env_specs.append(entry) + + return [s["name"] for s in env_specs if s.get("name") and not get_env_value(s["name"])] + + def _prompt_plugin_env_vars(manifest: dict, console) -> None: """Prompt for required environment variables declared in plugin.yaml. @@ -283,6 +306,95 @@ def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path: # --------------------------------------------------------------------------- +def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, str]: + """Clone Git plugin into ``~/.hermes/plugins``. + + Returns ``(target_dir, installed_manifest, canonical_name)``. + Raises ``PluginOperationError`` on failure. + """ + import tempfile + + try: + git_url = _resolve_git_url(identifier) + except ValueError as e: + raise PluginOperationError(str(e)) from e + + plugins_dir = _plugins_dir() + + with tempfile.TemporaryDirectory() as tmp: + tmp_target = Path(tmp) / "plugin" + + try: + result = subprocess.run( + ["git", "clone", "--depth", "1", git_url, str(tmp_target)], + capture_output=True, + text=True, + timeout=60, + ) + except FileNotFoundError as e: + raise PluginOperationError( + "git is not installed or not in PATH.", + ) from e + except subprocess.TimeoutExpired as e: + raise PluginOperationError( + "Git clone timed out after 60 seconds.", + ) from e + + if result.returncode != 0: + err = (result.stderr or result.stdout or "").strip() + raise PluginOperationError(f"Git clone failed:\n{err}") + + manifest = _read_manifest(tmp_target) + plugin_name = manifest.get("name") or _repo_name_from_url(git_url) + + try: + target = _sanitize_plugin_name(plugin_name, plugins_dir) + except ValueError as e: + raise PluginOperationError(str(e)) from e + + mv = manifest.get("manifest_version") + if mv is not None: + try: + mv_int = int(mv) + except (ValueError, TypeError): + raise PluginOperationError( + f"Plugin '{plugin_name}' has invalid manifest_version " + f"'{mv}' (expected an integer).", + ) from None + if mv_int > _SUPPORTED_MANIFEST_VERSION: + from hermes_cli.config import recommended_update_command + + raise PluginOperationError( + f"Plugin '{plugin_name}' requires manifest_version {mv}, " + f"but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}. " + f"Run {recommended_update_command()} to update Hermes.", + ) from None + + if target.exists(): + if not force: + raise PluginOperationError( + f"Plugin '{plugin_name}' already exists. Use force reinstall " + f"or run `hermes plugins update {plugin_name}`.", + ) + shutil.rmtree(target) + + shutil.move(str(tmp_target), str(target)) + + has_yaml = (target / "plugin.yaml").exists() or (target / "plugin.yml").exists() + if not has_yaml and not (target / "__init__.py").exists(): + logger.warning( + "%s has no plugin.yaml / __init__.py; may not be a valid plugin", + plugin_name, + ) + + from rich.console import Console + + _copy_example_files(target, Console()) + installed_manifest = _read_manifest(target) + installed_name = installed_manifest.get("name") or target.name + return target, installed_manifest, installed_name + + def cmd_install( identifier: str, force: bool = False, @@ -293,7 +405,6 @@ def cmd_install( After install, prompt "Enable now? [y/N]" unless *enable* is provided (True = auto-enable without prompting, False = install disabled). """ - import tempfile from rich.console import Console console = Console() @@ -304,114 +415,41 @@ def cmd_install( console.print(f"[red]Error:[/red] {e}") sys.exit(1) - # Warn about insecure / local URL schemes if git_url.startswith(("http://", "file://")): console.print( "[yellow]Warning:[/yellow] Using insecure/local URL scheme. " - "Consider using https:// or git@ for production installs." + "Consider using https:// or git@ for production installs.", ) - plugins_dir = _plugins_dir() + console.print(f"[dim]Cloning {git_url}...[/dim]") - # Clone into a temp directory first so we can read plugin.yaml for the name - with tempfile.TemporaryDirectory() as tmp: - tmp_target = Path(tmp) / "plugin" - console.print(f"[dim]Cloning {git_url}...[/dim]") + try: + target, installed_manifest, installed_name = _install_plugin_core( + identifier, + force=force, + ) + except PluginOperationError as e: + console.print(f"[red]Error:[/red] {e}") + sys.exit(1) - try: - result = subprocess.run( - ["git", "clone", "--depth", "1", git_url, str(tmp_target)], - capture_output=True, - text=True, - timeout=60, - ) - except FileNotFoundError: - console.print("[red]Error:[/red] git is not installed or not in PATH.") - sys.exit(1) - except subprocess.TimeoutExpired: - console.print("[red]Error:[/red] Git clone timed out after 60 seconds.") - sys.exit(1) - - if result.returncode != 0: - console.print( - f"[red]Error:[/red] Git clone failed:\n{result.stderr.strip()}" - ) - sys.exit(1) - - # Read manifest - manifest = _read_manifest(tmp_target) - plugin_name = manifest.get("name") or _repo_name_from_url(git_url) - - # Sanitize plugin name against path traversal - try: - target = _sanitize_plugin_name(plugin_name, plugins_dir) - except ValueError as e: - console.print(f"[red]Error:[/red] {e}") - sys.exit(1) - - # Check manifest_version compatibility - mv = manifest.get("manifest_version") - if mv is not None: - try: - mv_int = int(mv) - except (ValueError, TypeError): - console.print( - f"[red]Error:[/red] Plugin '{plugin_name}' has invalid " - f"manifest_version '{mv}' (expected an integer)." - ) - sys.exit(1) - if mv_int > _SUPPORTED_MANIFEST_VERSION: - from hermes_cli.config import recommended_update_command - console.print( - f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version " - f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n" - f"Run [bold]{recommended_update_command()}[/bold] to get a newer installer." - ) - sys.exit(1) - - if target.exists(): - if not force: - console.print( - f"[red]Error:[/red] Plugin '{plugin_name}' already exists at {target}.\n" - f"Use [bold]--force[/bold] to remove and reinstall, or " - f"[bold]hermes plugins update {plugin_name}[/bold] to pull latest." - ) - sys.exit(1) - console.print(f"[dim] Removing existing {plugin_name}...[/dim]") - shutil.rmtree(target) - - # Move from temp to final location - shutil.move(str(tmp_target), str(target)) - - # Validate it looks like a plugin - if not (target / "plugin.yaml").exists() and not (target / "__init__.py").exists(): + if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not ( + target / "__init__.py" + ).exists(): console.print( - f"[yellow]Warning:[/yellow] {plugin_name} doesn't contain plugin.yaml " - f"or __init__.py. It may not be a valid Hermes plugin." + f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml " + f"or __init__.py. It may not be a valid Hermes plugin.", ) - # Copy .example files to their real names (e.g. config.yaml.example → config.yaml) - _copy_example_files(target, console) - - # Re-read manifest from installed location (for env var prompting) - installed_manifest = _read_manifest(target) - - # Prompt for required environment variables before showing after-install docs _prompt_plugin_env_vars(installed_manifest, console) _display_after_install(target, identifier) - # Determine the canonical plugin name for enable-list bookkeeping. - installed_name = installed_manifest.get("name") or target.name - - # Decide whether to enable: explicit flag > interactive prompt > default off should_enable = enable if should_enable is None: - # Interactive prompt unless stdin isn't a TTY (scripted install). if sys.stdin.isatty() and sys.stdout.isatty(): try: answer = input( - f" Enable '{installed_name}' now? [y/N]: " + f" Enable '{installed_name}' now? [y/N]: ", ).strip().lower() should_enable = answer in ("y", "yes") except (EOFError, KeyboardInterrupt): @@ -427,12 +465,12 @@ def cmd_install( _save_enabled_set(enabled) _save_disabled_set(disabled) console.print( - f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled." + f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.", ) else: console.print( f"[dim]Plugin installed but not enabled. " - f"Run `hermes plugins enable {installed_name}` to activate.[/dim]" + f"Run `hermes plugins enable {installed_name}` to activate.[/dim]", ) console.print("[dim]Restart the gateway for the plugin to take effect:[/dim]") @@ -462,36 +500,22 @@ def cmd_update(name: str) -> None: console.print(f"[dim]Updating {name}...[/dim]") - try: - result = subprocess.run( - ["git", "pull", "--ff-only"], - capture_output=True, - text=True, - timeout=60, - cwd=str(target), - ) - except FileNotFoundError: - console.print("[red]Error:[/red] git is not installed or not in PATH.") - sys.exit(1) - except subprocess.TimeoutExpired: - console.print("[red]Error:[/red] Git pull timed out after 60 seconds.") - sys.exit(1) - - if result.returncode != 0: - console.print(f"[red]Error:[/red] Git pull failed:\n{result.stderr.strip()}") + ok, output = _git_pull_plugin_dir(target) + if not ok: + console.print(f"[red]Error:[/red] {output}") sys.exit(1) # Copy any new .example files _copy_example_files(target, console) - output = result.stdout.strip() - if "Already up to date" in output: + out = output.strip() + if "Already up to date" in out: console.print( f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date." ) else: console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.") - console.print(f"[dim]{output}[/dim]") + console.print(f"[dim]{out}[/dim]") def cmd_remove(name: str) -> None: @@ -1244,6 +1268,247 @@ def _run_composite_fallback(plugin_names, plugin_labels, plugin_selected, print() +def dashboard_install_plugin( + identifier: str, + *, + force: bool, + enable: bool, +) -> dict[str, Any]: + """Non-interactive install for the web dashboard. Returns a JSON-serializable dict.""" + warnings: list[str] = [] + try: + git_url = _resolve_git_url(identifier) + if git_url.startswith(("http://", "file://")): + warnings.append( + "Insecure URL scheme; prefer https:// or git@ for production installs.", + ) + except ValueError: + pass + + try: + target, installed_manifest, installed_name = _install_plugin_core( + identifier, + force=force, + ) + except PluginOperationError as exc: + return {"ok": False, "error": str(exc)} + + missing_env = _missing_requires_env_names(installed_manifest) + if enable: + en = _get_enabled_set() + dis = _get_disabled_set() + en.add(installed_name) + dis.discard(installed_name) + _save_enabled_set(en) + _save_disabled_set(dis) + + hint: str | None = None + ap = target / "after-install.md" + if ap.exists(): + hint = str(ap) + + return { + "ok": True, + "plugin_name": installed_name, + "warnings": warnings, + "missing_env": missing_env, + "after_install_path": hint, + "enabled": enable, + } + + +def _get_plugin_toolset_key(name: str) -> Optional[str]: + """Return the toolset key a plugin registers its tools under, or None. + + Queries the live tool registry — the plugin must already be loaded. + Falls back to reading ``provides_tools`` from plugin.yaml and looking + up the toolset from the registry for the first tool name found. + """ + try: + from tools.registry import registry + except Exception: + return None + + # Check the plugin manager for tools this plugin registered + try: + from hermes_cli.plugins import discover_plugins, get_plugin_manager + discover_plugins() # idempotent — ensures plugins are loaded + manager = get_plugin_manager() + for _key, loaded in manager._plugins.items(): + if loaded.manifest.name == name or _key == name: + for tool_name in loaded.tools_registered: + entry = registry.get_entry(tool_name) + if entry and entry.toolset: + return entry.toolset + break + except Exception: + pass + + # Fallback: read provides_tools from manifest on disk and query registry + try: + from hermes_cli.plugins import get_bundled_plugins_dir + for base in (get_bundled_plugins_dir(), _plugins_dir()): + if not base.is_dir(): + continue + candidate = base / name + if candidate.is_dir(): + manifest = _read_manifest(candidate) + for tool_name in manifest.get("provides_tools") or []: + entry = registry.get_entry(tool_name) + if entry and entry.toolset: + return entry.toolset + except Exception: + pass + + return None + + +def _toggle_plugin_toolset(name: str, *, enable: bool) -> None: + """Add or remove a plugin's toolset from platform_toolsets for all platforms. + + Only acts if the plugin actually provides tools (has a toolset key). + """ + toolset_key = _get_plugin_toolset_key(name) + if not toolset_key: + return + + from hermes_cli.config import load_config, save_config + + config = load_config() + platform_toolsets = config.get("platform_toolsets") + if not isinstance(platform_toolsets, dict): + platform_toolsets = {} + config["platform_toolsets"] = platform_toolsets + + changed = False + for platform, ts_list in platform_toolsets.items(): + if not isinstance(ts_list, list): + continue + if enable: + if toolset_key not in ts_list: + ts_list.append(toolset_key) + changed = True + else: + if toolset_key in ts_list: + ts_list.remove(toolset_key) + changed = True + + # If enabling and no platforms have toolset lists yet, add to "cli" at minimum + if enable and not changed and not platform_toolsets: + platform_toolsets["cli"] = [toolset_key] + changed = True + + if changed: + save_config(config) + + +def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str, Any]: + """Enable or disable a plugin in ``config.yaml`` (runtime allow/deny lists). + + For plugins that provide tools (toolsets), also toggles the toolset in + ``platform_toolsets`` so the agent actually sees the tools in sessions. + """ + if not _plugin_exists(name): + return {"ok": False, "error": f"Plugin '{name}' is not installed or bundled."} + + en = _get_enabled_set() + dis = _get_disabled_set() + + if enabled: + if name in en and name not in dis: + return {"ok": True, "name": name, "unchanged": True} + en.add(name) + dis.discard(name) + _save_enabled_set(en) + _save_disabled_set(dis) + _toggle_plugin_toolset(name, enable=True) + return {"ok": True, "name": name, "unchanged": False} + + if name not in en and name in dis: + return {"ok": True, "name": name, "unchanged": True} + + en.discard(name) + dis.add(name) + _save_enabled_set(en) + _save_disabled_set(dis) + _toggle_plugin_toolset(name, enable=False) + return {"ok": True, "name": name, "unchanged": False} + + +def _user_installed_plugin_dir(name: str) -> Optional[Path]: + """Resolved path under ``~/.hermes/plugins/`` if it exists.""" + plugins_dir = _plugins_dir() + try: + target = _sanitize_plugin_name(name, plugins_dir) + except ValueError: + return None + return target if target.is_dir() else None + + +def dashboard_update_user_plugin(name: str) -> dict[str, Any]: + """``git pull`` inside ``~/.hermes/plugins/``.""" + target = _user_installed_plugin_dir(name) + if target is None: + return { + "ok": False, + "error": f"Plugin '{name}' was not found under {_plugins_dir()}.", + } + + if not (target / ".git").exists(): + return { + "ok": False, + "error": f"Plugin '{name}' is not a git checkout; cannot pull updates.", + } + + ok, msg = _git_pull_plugin_dir(target) + if not ok: + return {"ok": False, "error": msg} + + from rich.console import Console + + _copy_example_files(target, Console()) + unchanged = "Already up to date" in msg + return {"ok": True, "name": name, "output": msg, "unchanged": unchanged} + + +def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]: + try: + result = subprocess.run( + ["git", "pull", "--ff-only"], + capture_output=True, + text=True, + timeout=60, + cwd=str(target), + ) + except FileNotFoundError: + return False, "git is not installed or not in PATH." + except subprocess.TimeoutExpired: + return False, "Git pull timed out after 60 seconds." + + if result.returncode != 0: + err = (result.stderr or "").strip() or result.stdout.strip() + return False, err or "git pull failed." + return True, result.stdout.strip() + + +def dashboard_remove_user_plugin(name: str) -> dict[str, Any]: + """Delete a plugin tree under ``~/.hermes/plugins/`` only.""" + plugins_dir = _plugins_dir() + for n, _ver, _d, src, _path in _discover_all_plugins(): + if n == name and src == "bundled": + return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."} + + target = _user_installed_plugin_dir(name) + if target is None: + return { + "ok": False, + "error": f"Plugin '{name}' was not found under {plugins_dir}.", + } + + shutil.rmtree(target) + return {"ok": True, "name": name} + + def plugins_command(args) -> None: """Dispatch hermes plugins subcommands.""" action = getattr(args, "plugins_action", None) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 570a0a7a88..300cfef4a5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3633,6 +3633,223 @@ async def rescan_dashboard_plugins(): return {"ok": True, "count": len(plugins)} +class _AgentPluginInstallBody(BaseModel): + identifier: str + force: bool = False + enable: bool = True + + +def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]: + return {k: v for k, v in p.items() if not k.startswith("_")} + + +def _merged_plugins_hub() -> Dict[str, Any]: + """Agent discovery + dashboard manifests + optional provider picker metadata.""" + from hermes_cli.plugins_cmd import ( + _discover_all_plugins, + _get_current_context_engine, + _get_current_memory_provider, + _discover_context_engines, + _discover_memory_providers, + _get_disabled_set, + _get_enabled_set, + _read_manifest as _read_plugin_manifest_at, + ) + + dashboard_list = _get_dashboard_plugins() + dash_by_name = {str(p["name"]): p for p in dashboard_list} + + disabled_set = _get_disabled_set() + enabled_set = _get_enabled_set() + + plugins_root_resolved = (get_hermes_home() / "plugins").resolve() + rows: List[Dict[str, Any]] = [] + + for name, version, description, source, dir_str in _discover_all_plugins(): + if name in disabled_set: + runtime_status = "disabled" + elif name in enabled_set: + runtime_status = "enabled" + else: + runtime_status = "inactive" + + dir_path = Path(dir_str) + dm = dash_by_name.get(name) + has_dash_manifest = dm is not None or (dir_path / "dashboard" / "manifest.json").exists() + + under_user_tree = False + try: + dir_path.resolve().relative_to(plugins_root_resolved) + under_user_tree = True + except ValueError: + pass + + can_remove_update = ( + source in ("user", "git") and under_user_tree and Path(dir_str).is_dir() + ) + + # Check if this plugin provides tools that require auth + auth_required = False + auth_command = "" + manifest_data = _read_plugin_manifest_at(dir_path) + provides_tools = manifest_data.get("provides_tools") or [] + if provides_tools: + try: + from tools.registry import registry + for tname in provides_tools: + entry = registry.get_entry(tname) + if entry and entry.check_fn and not entry.check_fn(): + auth_required = True + auth_command = f"hermes auth {name}" + break + except Exception: + pass + + rows.append({ + "name": name, + "version": version or "", + "description": description or "", + "source": source, + "runtime_status": runtime_status, + "has_dashboard_manifest": has_dash_manifest, + "dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None, + "path": dir_str, + "can_remove": can_remove_update, + "can_update_git": can_remove_update and (Path(dir_str) / ".git").exists(), + "auth_required": auth_required, + "auth_command": auth_command, + }) + + agent_names = {r["name"] for r in rows} + orphan_dashboard = [ + _strip_dashboard_manifest(p) + for p in dashboard_list + if str(p["name"]) not in agent_names + ] + + memory_providers: List[Dict[str, str]] = [] + try: + for n, desc in _discover_memory_providers(): + memory_providers.append({"name": n, "description": desc}) + except Exception: + memory_providers = [] + + context_engines: List[Dict[str, str]] = [] + try: + for n, desc in _discover_context_engines(): + context_engines.append({"name": n, "description": desc}) + except Exception: + context_engines = [] + + return { + "plugins": rows, + "orphan_dashboard_plugins": orphan_dashboard, + "providers": { + "memory_provider": _get_current_memory_provider() or "", + "memory_options": memory_providers, + "context_engine": _get_current_context_engine(), + "context_options": context_engines, + }, + } + + +@app.get("/api/dashboard/plugins/hub") +async def get_plugins_hub(request: Request): + """Unified agent plugins + dashboard extension metadata (session protected).""" + _require_token(request) + try: + return _merged_plugins_hub() + except Exception as exc: + _log.warning("plugins/hub failed: %s", exc) + raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc + + +@app.post("/api/dashboard/agent-plugins/install") +async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody): + _require_token(request) + from hermes_cli.plugins_cmd import dashboard_install_plugin + + result = dashboard_install_plugin( + body.identifier.strip(), + force=body.force, + enable=body.enable, + ) + if not result.get("ok"): + raise HTTPException( + status_code=400, + detail=result.get("error") or "Install failed.", + ) + _get_dashboard_plugins(force_rescan=True) + return result + + +@app.post("/api/dashboard/agent-plugins/{name}/enable") +async def post_agent_plugin_enable(request: Request, name: str): + _require_token(request) + from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled + + result = dashboard_set_agent_plugin_enabled(name, enabled=True) + if not result.get("ok"): + raise HTTPException(status_code=400, detail=result.get("error") or "Enable failed.") + return result + + +@app.post("/api/dashboard/agent-plugins/{name}/disable") +async def post_agent_plugin_disable(request: Request, name: str): + _require_token(request) + from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled + + result = dashboard_set_agent_plugin_enabled(name, enabled=False) + if not result.get("ok"): + raise HTTPException(status_code=400, detail=result.get("error") or "Disable failed.") + return result + + +@app.post("/api/dashboard/agent-plugins/{name}/update") +async def post_agent_plugin_update(request: Request, name: str): + _require_token(request) + from hermes_cli.plugins_cmd import dashboard_update_user_plugin + + result = dashboard_update_user_plugin(name) + if not result.get("ok"): + raise HTTPException(status_code=400, detail=result.get("error") or "Update failed.") + _get_dashboard_plugins(force_rescan=True) + return result + + +@app.delete("/api/dashboard/agent-plugins/{name}") +async def delete_agent_plugin(request: Request, name: str): + _require_token(request) + from hermes_cli.plugins_cmd import dashboard_remove_user_plugin + + result = dashboard_remove_user_plugin(name) + if not result.get("ok"): + raise HTTPException(status_code=400, detail=result.get("error") or "Remove failed.") + _get_dashboard_plugins(force_rescan=True) + return result + + +class _PluginProvidersPutBody(BaseModel): + memory_provider: Optional[str] = None + context_engine: Optional[str] = None + + +@app.put("/api/dashboard/plugin-providers") +async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody): + """Persist memory provider / context engine selection (writes config.yaml).""" + _require_token(request) + from hermes_cli.plugins_cmd import ( + _save_context_engine, + _save_memory_provider, + ) + + if body.memory_provider is not None: + _save_memory_provider(body.memory_provider) + if body.context_engine is not None: + _save_context_engine(body.context_engine) + return {"ok": True} + + @app.get("/dashboard-plugins/{plugin_name}/{file_path:path}") async def serve_plugin_asset(plugin_name: str, file_path: str): """Serve static assets from a dashboard plugin directory. diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 017e9913bd..2efd64fe40 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -124,6 +124,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -501,31 +502,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1700,6 +1676,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1710,6 +1687,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1720,6 +1698,7 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -1749,6 +1728,7 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -2066,6 +2046,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2468,6 +2449,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3203,6 +3185,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3334,6 +3317,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4242,6 +4226,7 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -5678,6 +5663,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5787,6 +5773,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6611,6 +6598,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6737,6 +6725,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6846,6 +6835,7 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -7261,6 +7251,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/App.tsx b/web/src/App.tsx index b03beef8e0..813f48cc5f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -65,10 +65,12 @@ import ModelsPage from "@/pages/ModelsPage"; import CronPage from "@/pages/CronPage"; import ProfilesPage from "@/pages/ProfilesPage"; import SkillsPage from "@/pages/SkillsPage"; +import PluginsPage from "@/pages/PluginsPage"; import ChatPage from "@/pages/ChatPage"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { useI18n } from "@/i18n"; +import type { Translations } from "@/i18n/types"; import { PluginPage, PluginSlot, usePlugins } from "@/plugins"; import type { PluginManifest } from "@/plugins"; import { useTheme } from "@/themes"; @@ -102,6 +104,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/logs": LogsPage, "/cron": CronPage, "/skills": SkillsPage, + "/plugins": PluginsPage, "/profiles": ProfilesPage, "/config": ConfigPage, "/env": EnvPage, @@ -138,6 +141,7 @@ const BUILTIN_NAV_REST: NavItem[] = [ { path: "/logs", labelKey: "logs", label: "Logs", icon: FileText }, { path: "/cron", labelKey: "cron", label: "Cron", icon: Clock }, { path: "/skills", labelKey: "skills", label: "Skills", icon: Package }, + { path: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle }, { path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users }, { path: "/config", labelKey: "config", label: "Config", icon: Settings }, { path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound }, @@ -213,6 +217,22 @@ function buildNavItems( return items; } +/** Split merged nav into built-in sidebar entries vs plugin tabs, preserving plugin order hints. */ +function partitionSidebarNav( + builtIn: NavItem[], + manifests: PluginManifest[], +): { coreItems: NavItem[]; pluginItems: NavItem[] } { + const merged = buildNavItems(builtIn, manifests); + const builtinPaths = new Set(builtIn.map((i) => i.path)); + const coreItems: NavItem[] = []; + const pluginItems: NavItem[] = []; + for (const item of merged) { + if (builtinPaths.has(item.path)) coreItems.push(item); + else pluginItems.push(item); + } + return { coreItems, pluginItems }; +} + function buildRoutes( builtinRoutes: Record, manifests: PluginManifest[], @@ -253,6 +273,7 @@ function buildRoutes( for (const m of addons) { if (m.tab.hidden) continue; + if (m.tab.path === "/plugins") continue; if (builtinRoutes[m.tab.path]) continue; routes.push({ key: `plugin:${m.name}`, @@ -263,6 +284,7 @@ function buildRoutes( for (const m of manifests) { if (!m.tab.hidden) continue; + if (m.tab.path === "/plugins") continue; if (builtinRoutes[m.tab.path] || m.tab.override) continue; routes.push({ key: `plugin:hidden:${m.name}`, @@ -322,8 +344,8 @@ export default function App() { [embeddedChat], ); - const navItems = useMemo( - () => buildNavItems(builtinNav, manifests), + const sidebarNav = useMemo( + () => partitionSidebarNav(builtinNav, manifests), [builtinNav, manifests], ); const routes = useMemo( @@ -476,56 +498,44 @@ export default function App() { aria-label={t.app.navigation} >
    - {navItems.map(({ path, label, labelKey, icon: Icon }) => { - const navLabel = labelKey - ? ((t.app.nav as Record)[labelKey] ?? label) - : label; - return ( -
  • - - cn( - "group relative flex items-center gap-3", - "px-5 py-2.5", - "font-mondwest text-[0.8rem] tracking-[0.12em]", - "whitespace-nowrap transition-colors cursor-pointer", - "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground", - isActive - ? "text-midground" - : "opacity-60 hover:opacity-100", - ) - } - style={{ - clipPath: "var(--component-tab-clip-path)", - }} - > - {({ isActive }) => ( - <> - - {navLabel} - - - - {isActive && ( - - )} - - )} - -
  • - ); - })} + {sidebarNav.coreItems.map((item) => ( + + ))}
+ + {sidebarNav.pluginItems.length > 0 && ( +
+ + {t.app.pluginNavSection} + + +
    + {sidebarNav.pluginItems.map((item) => ( + + ))} +
+
+ )} @@ -615,6 +625,57 @@ export default function App() { ); } +function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) { + const { path, label, labelKey, icon: Icon } = item; + + const navLabel = labelKey + ? ((t.app.nav as Record)[labelKey] ?? label) + : label; + + return ( +
  • + + cn( + "group relative flex items-center gap-3", + "px-5 py-2.5", + "font-mondwest text-[0.8rem] tracking-[0.12em]", + "whitespace-nowrap transition-colors cursor-pointer", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground", + isActive ? "text-midground" : "opacity-60 hover:opacity-100", + ) + } + style={{ + clipPath: "var(--component-tab-clip-path)", + }} + > + {({ isActive }) => ( + <> + + {navLabel} + + + + {isActive && ( + + )} + + )} + +
  • + ); +} + function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) { const { t } = useI18n(); const navigate = useNavigate(); @@ -733,6 +794,12 @@ interface NavItem { path: string; } +interface SidebarNavLinkProps { + closeMobile: () => void; + item: NavItem; + t: Translations; +} + interface SystemActionItem { action: SystemAction; icon: ComponentType<{ className?: string }>; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1aaabd0f63..9c0b92ca6d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -76,6 +76,7 @@ export const en: Translations = { logs: "Logs", models: "Models", profiles: "profiles : multi agents", + plugins: "Plugins", sessions: "Sessions", skills: "Skills", }, @@ -84,6 +85,7 @@ export const en: Translations = { navigation: "Navigation", openDocumentation: "Open documentation in a new tab", openNavigation: "Open navigation", + pluginNavSection: "Plugins", sessionsActiveCount: "{count} active", statusOverview: "Status overview", system: "System", @@ -256,6 +258,45 @@ export const en: Translations = { renamed: "Renamed", }, + pluginsPage: { + contextEngineLabel: "Context engine", + dashboardSlots: "Dashboard slots", + disableRuntime: "Disable", + enableAfterInstall: "Enable after install", + enableRuntime: "Enable", + forceReinstall: "Force reinstall (delete existing folder first)", + headline: + "Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).", + identifierLabel: "Git URL or owner/repo", + inactive: "inactive", + installBtn: "Install from Git", + installHeading: "Install from GitHub / Git URL", + installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.", + memoryProviderLabel: "Memory provider", + missingEnvWarn: "Set these in Keys before the plugin can run:", + noDashboardTab: "No dashboard tab", + openTab: "Open", + orphanHeading: "Dashboard-only extensions (no agent plugin.yaml match)", + pluginListHeading: "Installed plugins", + providerDefaults: "built-in / default", + providersHeading: "Runtime provider plugins", + providersHint: + "Writes memory.provider (empty = built-in) and context.engine to config.yaml. Takes effect next session.", + refreshDashboard: "Rescan dashboard extensions", + removeConfirm: "Remove this plugin from ~/.hermes/plugins/?", + removeHint: "Only user-installed plugins under ~/.hermes/plugins can be removed.", + rescanHeading: "SPA plugin registry", + rescanHint: "Rescan after adding files on disk so the dashboard sidebar picks up new manifests.", + runtimeHeading: "Gateway runtime (YAML plugins)", + saveProviders: "Save provider settings", + savedProviders: "Provider settings saved.", + sourceBadge: "Source", + authRequired: "Auth required", + authRequiredHint: "Run this command to authenticate:", + updateGit: "Git pull", + versionBadge: "Version", + }, + skills: { title: "Skills", searchPlaceholder: "Search skills and toolsets...", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index bb6266a2dd..4e67d7e9a4 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -76,6 +76,7 @@ export interface Translations { logs: string; models: string; profiles: string; + plugins: string; sessions: string; skills: string; }; @@ -84,6 +85,7 @@ export interface Translations { navigation: string; openDocumentation: string; openNavigation: string; + pluginNavSection: string; sessionsActiveCount: string; statusOverview: string; system: string; @@ -228,6 +230,44 @@ export interface Translations { }; }; + // ── Plugins page ── + pluginsPage: { + contextEngineLabel: string; + dashboardSlots: string; + disableRuntime: string; + enableAfterInstall: string; + enableRuntime: string; + forceReinstall: string; + headline: string; + identifierLabel: string; + inactive: string; + installBtn: string; + installHeading: string; + installHint: string; + memoryProviderLabel: string; + missingEnvWarn: string; + noDashboardTab: string; + openTab: string; + orphanHeading: string; + pluginListHeading: string; + providerDefaults: string; + providersHeading: string; + providersHint: string; + refreshDashboard: string; + removeConfirm: string; + removeHint: string; + rescanHeading: string; + rescanHint: string; + runtimeHeading: string; + saveProviders: string; + savedProviders: string; + sourceBadge: string; + authRequired: string; + authRequiredHint: string; + updateGit: string; + versionBadge: string; + }; + // ── Profiles page ── profiles: { newProfile: string; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index f7a7399af0..6eb726d483 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -75,6 +75,7 @@ export const zh: Translations = { logs: "日志", models: "模型", profiles: "多Agent配置", + plugins: "插件管理", sessions: "会话", skills: "技能", }, @@ -83,6 +84,7 @@ export const zh: Translations = { navigation: "导航", openDocumentation: "在新标签页中打开文档", openNavigation: "打开导航", + pluginNavSection: "插件", sessionsActiveCount: "{count} 个活跃", statusOverview: "状态概览", system: "系统", @@ -253,6 +255,44 @@ export const zh: Translations = { renamed: "已重命名", }, + pluginsPage: { + contextEngineLabel: "上下文引擎", + dashboardSlots: "面板插槽", + disableRuntime: "禁用", + enableAfterInstall: "安装后启用", + enableRuntime: "启用", + forceReinstall: "强制重装(先删除已有目录)", + headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI)。", + identifierLabel: "Git 地址或 owner/repo", + inactive: "未启用", + installBtn: "从 Git 安装", + installHeading: "从 GitHub / Git 地址安装", + installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。", + memoryProviderLabel: "记忆提供方", + missingEnvWarn: "在「密钥」页面设置以下变量后再运行插件:", + noDashboardTab: "无仪表盘标签", + openTab: "打开", + orphanHeading: "仅仪表盘扩展(无匹配的 agent plugin.yaml)", + pluginListHeading: "已安装插件", + providerDefaults: "内置 / 默认", + providersHeading: "运行时提供方插件", + providersHint: + "写入 config.yaml:memory.provider(留空为内置)、context.engine。下次会话生效。", + refreshDashboard: "重新扫描仪表盘扩展", + removeConfirm: "从 ~/.hermes/plugins/ 删除此插件?", + removeHint: "仅可移除用户安装在 ~/.hermes/plugins 下的插件。", + rescanHeading: "SPA 插件注册表", + rescanHint: "在磁盘新增文件后扫描,使侧边栏载入新 manifest。", + runtimeHeading: "网关运行时(YAML 插件)", + saveProviders: "保存提供方设置", + savedProviders: "提供方设置已保存。", + sourceBadge: "来源", + authRequired: "需要认证", + authRequiredHint: "运行此命令以完成认证:", + updateGit: "git pull", + versionBadge: "版本", + }, + skills: { title: "技能", searchPlaceholder: "搜索技能和工具集...", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 10ed9acf89..89cffea197 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -259,6 +259,46 @@ export const api = { rescanPlugins: () => fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"), + getPluginsHub: () => fetchJSON("/api/dashboard/plugins/hub"), + + installAgentPlugin: (body: AgentPluginInstallRequest) => + fetchJSON("/api/dashboard/agent-plugins/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body }), + }), + + enableAgentPlugin: (name: string) => + fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>( + `/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`, + { method: "POST" }, + ), + + disableAgentPlugin: (name: string) => + fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>( + `/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`, + { method: "POST" }, + ), + + updateAgentPlugin: (name: string) => + fetchJSON( + `/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`, + { method: "POST" }, + ), + + removeAgentPlugin: (name: string) => + fetchJSON<{ ok: boolean; name: string }>( + `/api/dashboard/agent-plugins/${encodeURIComponent(name)}`, + { method: "DELETE" }, + ), + + savePluginProviders: (body: PluginProvidersPutRequest) => + fetchJSON<{ ok: boolean }>("/api/dashboard/plugin-providers", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + // Dashboard themes getThemes: () => fetchJSON("/api/dashboard/themes"), @@ -668,8 +708,66 @@ export interface PluginManifestResponse { override?: string; hidden?: boolean; }; + slots?: string[]; entry: string; css?: string | null; has_api: boolean; source: string; } + +export interface HubAgentPluginRow { + name: string; + version: string; + description: string; + source: string; + runtime_status: "disabled" | "enabled" | "inactive"; + has_dashboard_manifest: boolean; + dashboard_manifest: PluginManifestResponse | null; + path: string; + can_remove: boolean; + can_update_git: boolean; + auth_required: boolean; + auth_command: string; +} + +export interface PluginsHubProviders { + memory_provider: string; + memory_options: Array<{ name: string; description: string }>; + context_engine: string; + context_options: Array<{ name: string; description: string }>; +} + +export interface PluginsHubResponse { + plugins: HubAgentPluginRow[]; + orphan_dashboard_plugins: PluginManifestResponse[]; + providers: PluginsHubProviders; +} + +export interface AgentPluginInstallRequest { + identifier: string; + force?: boolean; + enable?: boolean; +} + +export interface AgentPluginInstallResponse { + ok: boolean; + plugin_name?: string; + warnings?: string[]; + missing_env?: string[]; + after_install_path?: string | null; + enabled?: boolean; + error?: string; +} + +export interface AgentPluginUpdateResponse { + ok: boolean; + name?: string; + output?: string; + unchanged?: boolean; + error?: string; +} + +export interface PluginProvidersPutRequest { + memory_provider?: string; + context_engine?: string; +} diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx new file mode 100644 index 0000000000..b961c702b7 --- /dev/null +++ b/web/src/pages/PluginsPage.tsx @@ -0,0 +1,569 @@ +import { useCallback, useEffect, useState } from "react"; +import { ExternalLink, RefreshCw, Puzzle, Trash2 } from "lucide-react"; +import type { Translations } from "@/i18n/types"; +import { Link } from "react-router-dom"; +import { api } from "@/lib/api"; +import type { HubAgentPluginRow, PluginsHubResponse } from "@/lib/api"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; +import { Switch } from "@nous-research/ui/ui/components/switch"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { CommandBlock } from "@nous-research/ui/ui/components/command-block"; +import { H2 } from "@/components/NouiTypography"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/hooks/useToast"; +import { Toast } from "@/components/Toast"; +import { useI18n } from "@/i18n"; +import { PluginSlot } from "@/plugins"; +import { cn } from "@/lib/utils"; + +/** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */ +const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__"; + +export default function PluginsPage() { + const [hub, setHub] = useState(null); + const [loading, setLoading] = useState(true); + const [installId, setInstallId] = useState(""); + const [installForce, setInstallForce] = useState(false); + const [installEnable, setInstallEnable] = useState(true); + const [installBusy, setInstallBusy] = useState(false); + const [rescanBusy, setRescanBusy] = useState(false); + const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN); + const [contextSel, setContextSel] = useState("compressor"); + const [providerBusy, setProviderBusy] = useState(false); + const [rowBusy, setRowBusy] = useState(null); + + const { toast, showToast } = useToast(); + const { t } = useI18n(); + + const loadHub = useCallback(() => { + return api + .getPluginsHub() + .then((h) => { + setHub(h); + const p = h.providers; + setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN); + setContextSel(p.context_engine || "compressor"); + }) + .catch(() => showToast(t.common.loading, "error")); + }, [showToast, t.common.loading]); + + useEffect(() => { + setLoading(true); + void loadHub().finally(() => setLoading(false)); + }, [loadHub]); + + const onInstall = async () => { + const id = installId.trim(); + if (!id) { + showToast(t.pluginsPage.installHint, "error"); + return; + } + setInstallBusy(true); + try { + const r = await api.installAgentPlugin({ + identifier: id, + force: installForce, + enable: installEnable, + }); + showToast(`${r.plugin_name ?? id} installed`, "success"); + if ((r.warnings?.length ?? 0) > 0) showToast(r.warnings!.join(" "), "error"); + if ((r.missing_env?.length ?? 0) > 0) + showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error"); + setInstallId(""); + await loadHub(); + } catch (e) { + showToast(e instanceof Error ? e.message : "Install failed", "error"); + } finally { + setInstallBusy(false); + } + }; + + const onRescan = async () => { + setRescanBusy(true); + try { + const rc = await api.rescanPlugins(); + showToast( + `${t.pluginsPage.refreshDashboard} (${rc.count})`, + "success", + ); + await loadHub(); + } catch (e) { + showToast(e instanceof Error ? e.message : "Rescan failed", "error"); + } finally { + setRescanBusy(false); + } + }; + + const onSaveProviders = async () => { + setProviderBusy(true); + try { + await api.savePluginProviders({ + memory_provider: + memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel, + context_engine: contextSel, + }); + showToast(t.pluginsPage.savedProviders, "success"); + await loadHub(); + } catch (e) { + showToast(e instanceof Error ? e.message : "Save failed", "error"); + } finally { + setProviderBusy(false); + } + }; + + const setRuntimeLoading = async (name: string, fn: () => Promise) => { + setRowBusy(name); + try { + await fn(); + await loadHub(); + } catch (e) { + showToast(e instanceof Error ? e.message : "Failed", "error"); + } finally { + setRowBusy(null); + } + }; + + const rows = hub?.plugins ?? []; + const providers = hub?.providers; + + return ( +
    + + +
    + + +
    + +
    + + +

    {t.app.nav.plugins}

    + + +

    + {t.pluginsPage.headline} +

    +
    + + +
    + + {providers && ( + + + {t.pluginsPage.providersHeading} +

    + {t.pluginsPage.providersHint} +

    +
    + + + +
    +
    + + + +
    + +
    + + + +
    +
    + + +
    +
    + )} + + + + {t.pluginsPage.installHeading} +

    + {t.pluginsPage.installHint} +

    +
    + + + + +
    + + + + setInstallId(e.target.value)} + /> +
    + + +
    + +
    + + + + + {t.pluginsPage.forceReinstall} + +
    + +
    + + + + + {t.pluginsPage.enableAfterInstall} + +
    +
    + + + +

    + {t.pluginsPage.rescanHint} +

    + +

    + {t.pluginsPage.removeHint} +

    +
    +
    + +
    + +

    + {t.pluginsPage.pluginListHeading} +

    + + {loading ? ( + +
    + + + {t.common.loading} +
    + ) : rows.length === 0 ? ( + +

    {t.common.noResults}

    + ) : ( + +
      + + {rows.map((row: HubAgentPluginRow) => ( + +
    • + + + + +
    • + ))} +
    + )} +
    + + {(hub?.orphan_dashboard_plugins?.length ?? 0) > 0 ? ( + + +
    + +

    + {t.pluginsPage.orphanHeading} +

    + +
      + + {hub!.orphan_dashboard_plugins.map((m) => ( + +
    • + + + {m.label ?? m.name} — {m.description || m.tab?.path} + + + {!m.tab?.hidden ? ( + + + + + + + + {t.pluginsPage.openTab} + + ) : null} +
    • + ))} +
    +
    + ) : null} +
    + + + +
    + ); +} + +interface PluginRowCardProps { + + row: HubAgentPluginRow; + rowBusy: string | null; + setRuntimeLoading: ( + name: string, + fn: () => Promise, + ) => Promise; + + showToast: (msg: string, variant: "success" | "error") => void; + t: Translations; +} + +function PluginRowCard(props: PluginRowCardProps) { + const { + row, + rowBusy, + setRuntimeLoading, + showToast, + t, + } = props; + + const dm = row.dashboard_manifest; + + const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null; + + const busy = rowBusy === row.name; + + const badgeTone = + row.runtime_status === "enabled" + ? "success" + : row.runtime_status === "disabled" + ? "destructive" + : "outline"; + + return ( + + + + + + + +
    + + +
    + +
    + + {row.name} + + + {t.pluginsPage.sourceBadge}: {row.source} + + + + v{row.version || "—"} + + {row.runtime_status} + + {row.auth_required ? ( + {t.pluginsPage.authRequired} + ) : null} +
    + + {row.description ? ( + +

    + {row.description} +

    + ) : null} +
    + +
    + + + + + + + + {tabPath ? ( + + + {t.pluginsPage.openTab} + + ) : null} + + {row.can_update_git ? ( + + + ) : null} + + {row.can_remove ? ( + + + + ) : null} +
    +
    + + {dm?.slots?.length ? ( + +

    + {t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")} +

    + ) : null} + + {row.auth_required ? ( + + ) : null} + + {!row.has_dashboard_manifest && !dm ? ( + + +

    + {t.pluginsPage.noDashboardTab} +

    + ) : null} +
    + +
    + ); +} diff --git a/web/src/plugins/slots.ts b/web/src/plugins/slots.ts index eae6a816cb..2d3a04277c 100644 --- a/web/src/plugins/slots.ts +++ b/web/src/plugins/slots.ts @@ -46,6 +46,8 @@ import React, { Fragment, useEffect, useState } from "react"; * - `cron:bottom` — bottom of /cron page * - `skills:top` — top of /skills page * - `skills:bottom` — bottom of /skills page + * - `plugins:top` — top of /plugins page + * - `plugins:bottom` — bottom of /plugins page * - `config:top` — top of /config page * - `config:bottom` — bottom of /config page * - `env:top` — top of /env (Keys) page @@ -78,6 +80,8 @@ export const KNOWN_SLOT_NAMES = [ "cron:bottom", "skills:top", "skills:bottom", + "plugins:top", + "plugins:bottom", "config:top", "config:bottom", "env:top", From 6549b0f2b7feb6c0123a8eb9b550d6bac338f7f0 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 30 Apr 2026 18:50:28 -0400 Subject: [PATCH 2/6] fix(security): address CodeQL path-traversal and info-exposure findings - Add _validate_plugin_name() guard on all {name} path param endpoints (rejects /, \, .. before reaching plugin logic) - Strip after_install_path from install response (no internal paths to client) - Update nix/tui.nix lockfile hash to match committed package-lock.json --- hermes_cli/web_server.py | 13 +++++++++++++ nix/tui.nix | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 300cfef4a5..353fc8e608 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3780,12 +3780,22 @@ async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallB detail=result.get("error") or "Install failed.", ) _get_dashboard_plugins(force_rescan=True) + # Strip internal paths from the response + result.pop("after_install_path", None) return result +def _validate_plugin_name(name: str) -> str: + """Reject path-traversal attempts in plugin name URL parameters.""" + if not name or "/" in name or "\\" in name or ".." in name: + raise HTTPException(status_code=400, detail="Invalid plugin name.") + return name + + @app.post("/api/dashboard/agent-plugins/{name}/enable") async def post_agent_plugin_enable(request: Request, name: str): _require_token(request) + name = _validate_plugin_name(name) from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled result = dashboard_set_agent_plugin_enabled(name, enabled=True) @@ -3797,6 +3807,7 @@ async def post_agent_plugin_enable(request: Request, name: str): @app.post("/api/dashboard/agent-plugins/{name}/disable") async def post_agent_plugin_disable(request: Request, name: str): _require_token(request) + name = _validate_plugin_name(name) from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled result = dashboard_set_agent_plugin_enabled(name, enabled=False) @@ -3808,6 +3819,7 @@ async def post_agent_plugin_disable(request: Request, name: str): @app.post("/api/dashboard/agent-plugins/{name}/update") async def post_agent_plugin_update(request: Request, name: str): _require_token(request) + name = _validate_plugin_name(name) from hermes_cli.plugins_cmd import dashboard_update_user_plugin result = dashboard_update_user_plugin(name) @@ -3820,6 +3832,7 @@ async def post_agent_plugin_update(request: Request, name: str): @app.delete("/api/dashboard/agent-plugins/{name}") async def delete_agent_plugin(request: Request, name: str): _require_token(request) + name = _validate_plugin_name(name) from hermes_cli.plugins_cmd import dashboard_remove_user_plugin result = dashboard_remove_user_plugin(name) diff --git a/nix/tui.nix b/nix/tui.nix index 7453fa2673..4d27dde798 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-Chz+NW9NXqboXHOa6PKwf5bhAkkcFtKNhvKWwg2XSPc="; + hash = "sha256-a/HGI9OgVcTnZrMXA7xFMGnFoVxyHe95fulVz+WNYB0="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; From 7dc85495e05d9a955aef74f1aa5da18ea4b1cf52 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 30 Apr 2026 19:39:51 -0400 Subject: [PATCH 3/6] style(plugins): make page full width --- web/src/pages/PluginsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index b961c702b7..60e65be149 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -134,7 +134,7 @@ export default function PluginsPage() {
    -
    +
    From 9550d0fd46d11db3fda2aef7b5de3576819f1843 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 30 Apr 2026 19:51:13 -0400 Subject: [PATCH 4/6] fix(plugins): show 'Plugins' in page header instead of 'Web UI' Add /plugins route to resolve-page-title BUILTIN map. --- web/src/lib/resolve-page-title.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/lib/resolve-page-title.ts b/web/src/lib/resolve-page-title.ts index 00d2d1e6e4..afa5ed5cd3 100644 --- a/web/src/lib/resolve-page-title.ts +++ b/web/src/lib/resolve-page-title.ts @@ -7,6 +7,7 @@ const BUILTIN: Record = { "/logs": "logs", "/cron": "cron", "/skills": "skills", + "/plugins": "plugins", "/config": "config", "/env": "keys", "/docs": "documentation", From a52363231faacec860f19fd411ea1377202cd7ea Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 30 Apr 2026 19:53:41 -0400 Subject: [PATCH 5/6] refactor(plugins): move rescan button to page header, remove redundant title Use usePageHeader().setEnd to place the rescan button in the shared header bar. Remove the inline H2 title (already shown by the header) and the wrapper div. --- web/src/pages/PluginsPage.tsx | 45 ++++++++++++++--------------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index 60e65be149..c11fce2e51 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -10,7 +10,6 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Switch } from "@nous-research/ui/ui/components/switch"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { CommandBlock } from "@nous-research/ui/ui/components/command-block"; -import { H2 } from "@/components/NouiTypography"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -19,6 +18,7 @@ import { Toast } from "@/components/Toast"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; import { cn } from "@/lib/utils"; +import { usePageHeader } from "@/contexts/usePageHeader"; /** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */ const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__"; @@ -38,6 +38,7 @@ export default function PluginsPage() { const { toast, showToast } = useToast(); const { t } = useI18n(); + const { setEnd } = usePageHeader(); const loadHub = useCallback(() => { return api @@ -56,6 +57,22 @@ export default function PluginsPage() { void loadHub().finally(() => setLoading(false)); }, [loadHub]); + useEffect(() => { + setEnd( + , + ); + return () => setEnd(null); + }, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]); + const onInstall = async () => { const id = installId.trim(); if (!id) { @@ -136,32 +153,6 @@ export default function PluginsPage() {
    - -
    - -
    - - -

    {t.app.nav.plugins}

    - - -

    - {t.pluginsPage.headline} -

    -
    - - -
    - {providers && ( From c73b799de70d6f655533e2aaf61a22332982f09f Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 30 Apr 2026 20:02:15 -0400 Subject: [PATCH 6/6] feat(dashboard): add hide/show toggle for dashboard plugins in sidebar - New config key: dashboard.hidden_plugins (list of plugin names) - GET /api/dashboard/plugins now filters out hidden plugins from sidebar - POST /api/dashboard/plugins/{name}/visibility toggles visibility - Hub response includes user_hidden boolean per plugin row - Eye/EyeOff toggle on plugin cards with dashboard manifests - i18n: 'Show in sidebar' / 'Hide from sidebar' (en/zh) --- hermes_cli/web_server.py | 40 +++++++++++++++++++++++++++++++++-- web/src/i18n/en.ts | 2 ++ web/src/i18n/types.ts | 2 ++ web/src/i18n/zh.ts | 2 ++ web/src/lib/api.ts | 11 ++++++++++ web/src/pages/PluginsPage.tsx | 23 +++++++++++++++++++- 6 files changed, 77 insertions(+), 3 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 353fc8e608..0bb200430b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3617,12 +3617,16 @@ def _get_dashboard_plugins(force_rescan: bool = False) -> list: @app.get("/api/dashboard/plugins") async def get_dashboard_plugins(): - """Return discovered dashboard plugins.""" + """Return discovered dashboard plugins (excludes user-hidden ones).""" plugins = _get_dashboard_plugins() - # Strip internal fields before sending to frontend. + # Read user's hidden plugins list from config. + config = load_config() + hidden: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or [] + # Strip internal fields before sending to frontend and filter out hidden. return [ {k: v for k, v in p.items() if not k.startswith("_")} for p in plugins + if p["name"] not in hidden ] @@ -3662,6 +3666,10 @@ def _merged_plugins_hub() -> Dict[str, Any]: disabled_set = _get_disabled_set() enabled_set = _get_enabled_set() + # Read user-hidden plugins from config for the user_hidden field. + config = load_config() + hidden_plugins: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or [] + plugins_root_resolved = (get_hermes_home() / "plugins").resolve() rows: List[Dict[str, Any]] = [] @@ -3718,6 +3726,7 @@ def _merged_plugins_hub() -> Dict[str, Any]: "can_update_git": can_remove_update and (Path(dir_str) / ".git").exists(), "auth_required": auth_required, "auth_command": auth_command, + "user_hidden": name in hidden_plugins, }) agent_names = {r["name"] for r in rows} @@ -3863,6 +3872,33 @@ async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody): return {"ok": True} +class _PluginVisibilityBody(BaseModel): + hidden: bool + + +@app.post("/api/dashboard/plugins/{name}/visibility") +async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody): + """Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins).""" + _require_token(request) + name = _validate_plugin_name(name) + + config = load_config() + if "dashboard" not in config or not isinstance(config.get("dashboard"), dict): + config["dashboard"] = {} + hidden_list: list = config["dashboard"].get("hidden_plugins") or [] + if not isinstance(hidden_list, list): + hidden_list = [] + + if body.hidden and name not in hidden_list: + hidden_list.append(name) + elif not body.hidden and name in hidden_list: + hidden_list.remove(name) + + config["dashboard"]["hidden_plugins"] = hidden_list + save_config(config) + return {"ok": True, "name": name, "hidden": body.hidden} + + @app.get("/dashboard-plugins/{plugin_name}/{file_path:path}") async def serve_plugin_asset(plugin_name: str, file_path: str): """Serve static assets from a dashboard plugin directory. diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9c0b92ca6d..55e3267b1b 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -295,6 +295,8 @@ export const en: Translations = { authRequiredHint: "Run this command to authenticate:", updateGit: "Git pull", versionBadge: "Version", + showInSidebar: "Show in sidebar", + hideFromSidebar: "Hide from sidebar", }, skills: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 4e67d7e9a4..d93260d26d 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -266,6 +266,8 @@ export interface Translations { authRequiredHint: string; updateGit: string; versionBadge: string; + showInSidebar: string; + hideFromSidebar: string; }; // ── Profiles page ── diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6eb726d483..b64de0661f 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -291,6 +291,8 @@ export const zh: Translations = { authRequiredHint: "运行此命令以完成认证:", updateGit: "git pull", versionBadge: "版本", + showInSidebar: "在侧边栏显示", + hideFromSidebar: "从侧边栏隐藏", }, skills: { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 89cffea197..8fed709765 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -299,6 +299,16 @@ export const api = { body: JSON.stringify(body), }), + setPluginVisibility: (name: string, hidden: boolean) => + fetchJSON<{ ok: boolean; name: string; hidden: boolean }>( + `/api/dashboard/plugins/${encodeURIComponent(name)}/visibility`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hidden }), + }, + ), + // Dashboard themes getThemes: () => fetchJSON("/api/dashboard/themes"), @@ -728,6 +738,7 @@ export interface HubAgentPluginRow { can_update_git: boolean; auth_required: boolean; auth_command: string; + user_hidden: boolean; } export interface PluginsHubProviders { diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index c11fce2e51..17123cd9e3 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { ExternalLink, RefreshCw, Puzzle, Trash2 } from "lucide-react"; +import { ExternalLink, RefreshCw, Puzzle, Trash2, Eye, EyeOff } from "lucide-react"; import type { Translations } from "@/i18n/types"; import { Link } from "react-router-dom"; import { api } from "@/lib/api"; @@ -504,6 +504,27 @@ function PluginRowCard(props: PluginRowCardProps) { ) : null} + {row.has_dashboard_manifest ? ( + + ) : null} + {row.can_remove ? (