diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 352dadd194b..a13e1b212c6 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 570a0a7a882..300cfef4a56 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 017e9913bd9..2efd64fe406 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 b03beef8e04..813f48cc5fc 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 1aaabd0f633..9c0b92ca6d6 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 bb6266a2dda..4e67d7e9a4f 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 f7a7399af0d..6eb726d4839 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 10ed9acf890..89cffea1971 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 00000000000..b961c702b7a --- /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 eae6a816cbd..2d3a04277c8 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",